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
2 changes: 1 addition & 1 deletion .github/.release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "1.10.0"
".": "1.11.0"
}
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [1.11.0](https://github.com/trycompai/crm/compare/v1.10.0...v1.11.0) (2026-08-11)


### Features

* **app:** copy the tracking snippet for the selected install method ([#128](https://github.com/trycompai/crm/issues/128)) ([30e0137](https://github.com/trycompai/crm/commit/30e01377781559375c3a58ada50b63016dea7d57))

## [1.10.0](https://github.com/trycompai/crm/compare/v1.9.0...v1.10.0) (2026-08-11)


Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/tracking/tracking.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import {
Param,
Post,
Req,
Res,
ServiceUnavailableException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { AllowAnonymous } from "@thallesp/nestjs-better-auth";
import type { Response } from "express";
import type { EnvironmentVariables } from "../config/env.validation";
import { InjectDatabase } from "../database/database.constants";
import { TrackingConfigService } from "./tracking-config.service";
Expand Down Expand Up @@ -59,9 +61,12 @@ export class TrackingController {
@HttpCode(204)
async collect(
@Req() request: IncomingMessage,
@Res({ passthrough: true }) response: Response,
@Headers("origin") origin?: string,
@Headers("user-agent") userAgent?: string,
): Promise<void> {
response.setHeader("cross-origin-resource-policy", "cross-origin");

const raw = await read(request, MAX_BODY_BYTES);
if (!raw) return;

Expand Down
31 changes: 31 additions & 0 deletions apps/api/test/tracking-collector.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import type { INestApplication } from "@nestjs/common";
import request from "supertest";

describe("Tracking collector", () => {
let app: INestApplication;

beforeAll(async () => {
const { createApp } = await import("../src/create-app");

app = await createApp();
await app.init();
});

afterAll(async () => {
await app.close();
});

it("answers a beacon from another origin with a cross-origin CORP", async () => {
const response = await request(app.getHttpServer())
.post("/api/t/e")
.set("origin", "https://example.com")
.set("content-type", "text/plain")
.send(JSON.stringify({ siteId: "cmp_unknown", events: [] }));

expect(response.status).toBe(204);
expect(response.headers["cross-origin-resource-policy"]).toBe(
"cross-origin",
);
});
});
44 changes: 23 additions & 21 deletions apps/app/app/(app)/[slug]/settings/tracking/tracking-script.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { Label } from "@crm/ui/components/label";
import { StatusIndicator } from "@crm/ui/components/status-indicator";
import { Switch } from "@crm/ui/components/switch";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { useCrmCache } from "@/lib/trpc/cache";
import { useTRPC } from "@/lib/trpc/client";
Expand All @@ -41,6 +42,7 @@ export function TrackingScript() {
const trpc = useTRPC();
const cache = useCrmCache();
const tracking = useQuery(trpc.tracking.settings.queryOptions());
const [section, setSection] = useState("html");

const setFlag = useMutation(
trpc.tracking.setFlag.mutationOptions({
Expand Down Expand Up @@ -116,18 +118,29 @@ export function TrackingScript() {
</CardDescription>

<CardAction>
<Button size="sm" onClick={() => copy(snippet)} type="button">
<Button
size="sm"
onClick={() =>
copy(section === "gtm" ? tagManagerSnippet : snippet)
}
type="button"
>
<Icon icon={Copy} data-icon="inline-start" />
Copy
</Button>
</CardAction>
</CardHeader>

<CardContent>
<Accordion type="single" collapsible defaultValue="html">
<Accordion
type="single"
collapsible
value={section}
onValueChange={setSection}
>
<AccordionItem value="html">
<AccordionTrigger>Paste it into your HTML</AccordionTrigger>
<AccordionContent>
<AccordionContent className="flex flex-col gap-4">
<pre className="overflow-x-auto rounded-md border bg-muted p-4 font-mono text-code-foreground text-xs/5">
<span className="text-code-accent">{"<script"}</span>
{"\n src="}
Expand All @@ -149,7 +162,7 @@ export function TrackingScript() {
<AccordionTrigger>
Add it through Google Tag Manager
</AccordionTrigger>
<AccordionContent>
<AccordionContent className="flex flex-col gap-4">
<pre className="overflow-x-auto rounded-md border bg-muted p-4 font-mono text-code-foreground text-xs/5">
<span className="text-code-accent">{"<script"}</span>
{"\n src="}
Expand All @@ -168,23 +181,12 @@ export function TrackingScript() {
off any consent-blocked category you do not need.
</li>
</ol>
<div className="flex items-center justify-between gap-4">
<p className="text-muted-foreground text-xs/relaxed">
Tag Manager drops a{" "}
<span className="font-mono text-foreground">data-site</span>{" "}
attribute when it injects a script, so this form carries the
site ID in the URL instead.
</p>
<Button
size="xs"
variant="outline"
type="button"
onClick={() => copy(tagManagerSnippet)}
>
<Icon icon={Copy} data-icon="inline-start" />
Copy
</Button>
</div>
<p className="text-muted-foreground text-xs/relaxed">
Tag Manager drops a{" "}
<span className="font-mono text-foreground">data-site</span>{" "}
attribute when it injects a script, so this form carries the
site ID in the URL instead.
</p>
</AccordionContent>
</AccordionItem>
</Accordion>
Expand Down
8 changes: 8 additions & 0 deletions docs/tracking.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ somebody else's page. That imposes rules nothing else in this repo has:
`POST /api/t/e`, anonymous, 204, in `TrackingController`. It answers nothing: a
tracker that could read a response is a tracker whose failures a stranger can probe.

**It is the one route that sets `Cross-Origin-Resource-Policy: cross-origin`**, and
it must. `helmet()` puts `same-origin` on every response, which is right for an API
only its own app calls — but this one is called by a `no-cors` beacon on somebody
else's marketing site, so Chrome blocks the reply with
`ERR_BLOCKED_BY_RESPONSE.NotSameOrigin` and logs a failure under every page view.
The header is set on the response, not switched off in `helmet`, so the exception
stays with the route that needs it.

The gauntlet, in order, in `TrackingIngestService.accept`:

1. **User agent** — the `BOT` pattern.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "crm",
"private": true,
"license": "MIT",
"version": "1.10.0",
"version": "1.11.0",
"scripts": {
"prepare": "git rev-parse --git-dir >/dev/null 2>&1 && git config core.hooksPath .githooks || true",
"build": "turbo run build",
Expand Down