@agent-native/core 0.84.28 → 0.84.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,11 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.84.29
4
+
5
+ ### Patch Changes
6
+
7
+ - 5f60aaa: Fix hosted Google Analytics / Tag Manager injection by baking the measurement id into Nitro server bundles and merging the required GA/GTM script, connect, and image hosts into existing stricter document CSPs.
8
+
3
9
  ## 0.84.28
4
10
 
5
11
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.84.28",
3
+ "version": "0.84.29",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -2886,6 +2886,11 @@ export default bundle;
2886
2886
  virtual: {
2887
2887
  "virtual:agents-bundle": agentsBundleModuleSource,
2888
2888
  },
2889
+ replace: {
2890
+ "process.env.AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID": JSON.stringify(
2891
+ process.env.GA_MEASUREMENT_ID?.trim() || "",
2892
+ ),
2893
+ },
2889
2894
  // Replace browser-only renderers (Excalidraw/Mermaid) with an inert proxy in
2890
2895
  // the server bundle. Without this, Nitro's Rolldown build pulls the real
2891
2896
  // Excalidraw into a shared vendor chunk imported statically by the SSR render
@@ -4,8 +4,8 @@
4
4
  * - `GA_MEASUREMENT_ID` — Google Analytics 4 measurement ID
5
5
  *
6
6
  * Netlify configuration-file env vars are build-time only for serverless
7
- * functions, so the Vite plugin also bakes this public value into SSR bundles
8
- * as `__AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID__`.
7
+ * functions, so the Vite/Nitro build paths also bake this public value into
8
+ * SSR bundles.
9
9
  *
10
10
  * Amplitude and Sentry are initialized client-side via their npm packages
11
11
  * (see `packages/core/src/client/analytics.ts`). Only GA requires script
@@ -28,14 +28,17 @@ function normalizeMeasurementId(value: string | undefined): string | null {
28
28
  return trimmed ? trimmed : null;
29
29
  }
30
30
 
31
+ function getViteBakedGaMeasurementId(): string | undefined {
32
+ return typeof __AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID__ === "string"
33
+ ? __AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID__
34
+ : undefined;
35
+ }
36
+
31
37
  function getGaMeasurementId(): string | null {
32
38
  return (
33
39
  normalizeMeasurementId(process.env.GA_MEASUREMENT_ID) ||
34
- normalizeMeasurementId(
35
- typeof __AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID__ === "string"
36
- ? __AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID__
37
- : undefined,
38
- )
40
+ normalizeMeasurementId(process.env.AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID) ||
41
+ normalizeMeasurementId(getViteBakedGaMeasurementId())
39
42
  );
40
43
  }
41
44
 
@@ -51,6 +54,25 @@ export const GA_CSP_SCRIPT_HOSTS = [
51
54
  "https://www.google-analytics.com",
52
55
  ] as const;
53
56
 
57
+ /**
58
+ * Network/image hosts used by the GA4 loader when it sends page-view and event
59
+ * beacons. These are separate from `script-src`: a stricter deployment CSP with
60
+ * `connect-src 'self'` or `img-src 'self'` can load gtag.js but still drop all
61
+ * analytics events unless these hosts are present too.
62
+ */
63
+ export const GA_CSP_CONNECT_HOSTS = [
64
+ "https://www.google-analytics.com",
65
+ "https://analytics.google.com",
66
+ "https://stats.g.doubleclick.net",
67
+ "https://region1.google-analytics.com",
68
+ ] as const;
69
+
70
+ export const GA_CSP_IMG_HOSTS = [
71
+ "https://www.google-analytics.com",
72
+ "https://www.googletagmanager.com",
73
+ "https://stats.g.doubleclick.net",
74
+ ] as const;
75
+
54
76
  /**
55
77
  * The exact JS body (no surrounding `<script>` tags) of the inline gtag config
56
78
  * block injected next to the gtag.js loader. Returned so the SSR handler can
@@ -31,6 +31,8 @@ import {
31
31
  withAgentNativeSocialImageCacheBuster,
32
32
  } from "../shared/social-meta.js";
33
33
  import {
34
+ GA_CSP_CONNECT_HOSTS,
35
+ GA_CSP_IMG_HOSTS,
34
36
  GA_CSP_SCRIPT_HOSTS,
35
37
  getGaInlineConfigScriptBody,
36
38
  } from "./analytics.js";
@@ -270,6 +272,121 @@ function extractScriptBody(scriptTag: string | null): string | null {
270
272
  return scriptTag.slice(start, end);
271
273
  }
272
274
 
275
+ type CspDirective = {
276
+ name: string;
277
+ tokens: string[];
278
+ };
279
+
280
+ function parseCsp(policy: string): CspDirective[] {
281
+ return policy
282
+ .split(";")
283
+ .map((part) => part.trim())
284
+ .filter(Boolean)
285
+ .map((part) => {
286
+ const [name = "", ...tokens] = part.split(/\s+/);
287
+ return { name: name.toLowerCase(), tokens };
288
+ })
289
+ .filter((directive) => directive.name);
290
+ }
291
+
292
+ function serializeCsp(directives: CspDirective[]): string {
293
+ return directives
294
+ .map((directive) =>
295
+ [directive.name, ...directive.tokens].filter(Boolean).join(" "),
296
+ )
297
+ .join("; ");
298
+ }
299
+
300
+ function appendCspTokens(
301
+ tokens: string[],
302
+ additions: readonly string[],
303
+ ): string[] {
304
+ if (!additions.length) return tokens;
305
+ const next = tokens.filter((token) => token !== "'none'");
306
+ const seen = new Set(next);
307
+ for (const token of additions) {
308
+ if (!token || seen.has(token)) continue;
309
+ next.push(token);
310
+ seen.add(token);
311
+ }
312
+ return next;
313
+ }
314
+
315
+ function findCspDirective(
316
+ directives: CspDirective[],
317
+ name: string,
318
+ ): CspDirective | undefined {
319
+ return directives.find((directive) => directive.name === name);
320
+ }
321
+
322
+ function appendToExistingOrDefaultCspDirective(
323
+ directives: CspDirective[],
324
+ name: string,
325
+ additions: readonly string[],
326
+ ): void {
327
+ if (!additions.length) return;
328
+ const existing = findCspDirective(directives, name);
329
+ if (existing) {
330
+ existing.tokens = appendCspTokens(existing.tokens, additions);
331
+ return;
332
+ }
333
+
334
+ const defaultSrc = findCspDirective(directives, "default-src");
335
+ if (!defaultSrc) return;
336
+ directives.push({
337
+ name,
338
+ tokens: appendCspTokens([...defaultSrc.tokens], additions),
339
+ });
340
+ }
341
+
342
+ function appendToExistingCspDirective(
343
+ directives: CspDirective[],
344
+ name: string,
345
+ additions: readonly string[],
346
+ ): void {
347
+ const existing = findCspDirective(directives, name);
348
+ if (!existing) return;
349
+ existing.tokens = appendCspTokens(existing.tokens, additions);
350
+ }
351
+
352
+ function augmentExistingCspForFrameworkScripts(
353
+ policy: string,
354
+ options: {
355
+ scriptSrcTokens: readonly string[];
356
+ gaEnabled: boolean;
357
+ },
358
+ ): string {
359
+ const directives = parseCsp(policy);
360
+ if (!directives.length) return policy;
361
+
362
+ appendToExistingOrDefaultCspDirective(
363
+ directives,
364
+ "script-src",
365
+ options.scriptSrcTokens,
366
+ );
367
+ // `script-src-elem` overrides `script-src` for script tags when present.
368
+ appendToExistingCspDirective(
369
+ directives,
370
+ "script-src-elem",
371
+ options.scriptSrcTokens,
372
+ );
373
+
374
+ if (options.gaEnabled) {
375
+ appendToExistingOrDefaultCspDirective(
376
+ directives,
377
+ "connect-src",
378
+ GA_CSP_CONNECT_HOSTS,
379
+ );
380
+ appendToExistingOrDefaultCspDirective(
381
+ directives,
382
+ "img-src",
383
+ GA_CSP_IMG_HOSTS,
384
+ );
385
+ }
386
+
387
+ return serializeCsp(directives);
388
+ }
389
+
273
390
  /**
274
391
  * Apply a Content-Security-Policy header to HTML document responses.
275
392
  *
@@ -282,20 +399,27 @@ function extractScriptBody(scriptTag: string | null): string | null {
282
399
  * user-controlled content reaches the HTML).
283
400
  *
284
401
  * A third directive, `script-src`, is emitted via `Content-Security-Policy-
285
- * Report-Only` rather than enforced. The framework injects deterministic inline
286
- * scripts (the Sentry config block, whose hash is computed once at process
287
- * startup from the resolved env vars, and — when `GA_MEASUREMENT_ID` is set —
288
- * the gtag config block, whose hash is derived from the same string
289
- * `wrapWithAnalytics` embeds). It also loads Google Tag Manager / GA4 from
290
- * `GA_CSP_SCRIPT_HOSTS`. All of those are listed here so the report-only policy
291
- * reflects the code the framework itself injects instead of reporting a
292
- * violation on every page load. Templates additionally render a theme-init
293
- * inline script whose exact content varies by template (default theme param,
294
- * custom docs variant, etc.) and which is rendered by React Router, not this
295
- * handler, so its hash is not available here. Shipping script-src as
296
- * Report-Only surfaces the remaining violations without breaking template
297
- * customisations; teams can graduate to enforcement once their hashes are
298
- * enumerated.
402
+ * Report-Only` rather than enforced when the app has no existing document CSP.
403
+ * The framework injects deterministic inline scripts (the Sentry config block,
404
+ * whose hash is computed once at process startup from the resolved env vars,
405
+ * and — when `GA_MEASUREMENT_ID` is set — the gtag config block, whose hash is
406
+ * derived from the same string `wrapWithAnalytics` embeds). It also loads
407
+ * Google Tag Manager / GA4 from `GA_CSP_SCRIPT_HOSTS`. All of those are listed
408
+ * here so the report-only policy reflects the code the framework itself injects
409
+ * instead of reporting a violation on every page load.
410
+ *
411
+ * If an app or host already sends an enforced CSP with `script-src`,
412
+ * `script-src-elem`, `connect-src`, `img-src`, or `default-src`, we merge the
413
+ * framework's GA/GTM allowances into the existing directive. That keeps
414
+ * stricter deployments working without adding a new enforced script policy to
415
+ * routes that only declare unrelated directives such as `frame-ancestors`.
416
+ *
417
+ * Templates additionally render a theme-init inline script whose exact content
418
+ * varies by template (default theme param, custom docs variant, etc.) and which
419
+ * is rendered by React Router, not this handler, so its hash is not available
420
+ * here. Shipping script-src as Report-Only surfaces the remaining violations
421
+ * without breaking template customisations; teams can graduate to enforcement
422
+ * once their hashes are enumerated.
299
423
  *
300
424
  * Skipped in development (`NODE_ENV !== 'production'`) so HMR eval and Vite
301
425
  * dev-server injects are never blocked. Set `AGENT_NATIVE_DISABLE_DOC_CSP=1`
@@ -305,16 +429,6 @@ function applyDocumentCsp(headers: Headers, sentryScript: string | null): void {
305
429
  if (process.env.NODE_ENV !== "production") return;
306
430
  if (process.env.AGENT_NATIVE_DISABLE_DOC_CSP === "1") return;
307
431
 
308
- // object-src / base-uri: enforced; neither directive mentions scripts, so
309
- // they are safe even when a template's inline script hashes are unknown.
310
- const existing = headers.get("content-security-policy") ?? "";
311
- if (!existing) {
312
- headers.set(
313
- "content-security-policy",
314
- "object-src 'none'; base-uri 'self'",
315
- );
316
- }
317
-
318
432
  // script-src as Report-Only: list 'self', the framework-injected inline
319
433
  // script hashes (Sentry config + gtag config), and the Google Analytics /
320
434
  // Tag Manager loader hosts. These are exactly the scripts the framework
@@ -332,11 +446,33 @@ function applyDocumentCsp(headers: Headers, sentryScript: string | null): void {
332
446
  ...(gaHash ? [gaHash] : []),
333
447
  ...gaHosts,
334
448
  ];
335
- const scriptSrc = `script-src ${scriptSrcTokens.join(" ")}`;
336
449
 
450
+ const cspAugmentOptions = {
451
+ scriptSrcTokens,
452
+ gaEnabled: Boolean(gaInlineBody),
453
+ };
454
+ const existing = headers.get("content-security-policy") ?? "";
455
+ if (!existing) {
456
+ headers.set(
457
+ "content-security-policy",
458
+ "object-src 'none'; base-uri 'self'",
459
+ );
460
+ } else {
461
+ headers.set(
462
+ "content-security-policy",
463
+ augmentExistingCspForFrameworkScripts(existing, cspAugmentOptions),
464
+ );
465
+ }
466
+
467
+ const scriptSrc = `script-src ${scriptSrcTokens.join(" ")}`;
337
468
  const existingRo = headers.get("content-security-policy-report-only") ?? "";
338
469
  if (!existingRo) {
339
470
  headers.set("content-security-policy-report-only", scriptSrc);
471
+ } else {
472
+ headers.set(
473
+ "content-security-policy-report-only",
474
+ augmentExistingCspForFrameworkScripts(existingRo, cspAugmentOptions),
475
+ );
340
476
  }
341
477
  }
342
478
 
@@ -2086,6 +2086,9 @@ function createAgentNativeConfig(
2086
2086
  __AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID__: JSON.stringify(
2087
2087
  process.env.GA_MEASUREMENT_ID?.trim() || "",
2088
2088
  ),
2089
+ "process.env.AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID": JSON.stringify(
2090
+ process.env.GA_MEASUREMENT_ID?.trim() || "",
2091
+ ),
2089
2092
  // Framework route warmup controls how SSR `.data` routes are fetched:
2090
2093
  // ordinary fetches keep them CDN-cacheable, while native prefetch headers
2091
2094
  // can be refused before the CDN/origin sees the request. Keep this value