diff --git a/AGENTS.md b/AGENTS.md index 523b989f9..c5c338955 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ text it shows is a gist that must match `CLA.md`: change both together, as a new | `apps/worker` | `@coss/worker` | Bun background worker: email dispatch (pg-boss), scheduled sends, broadcast fan-out, webhook deliveries, domain re-checks, dedicated IP pools, stale-claim/unclaimed sweeps, data retention purges, organization deletions, SES events and received emails from SQS (`EMAIL_PROVIDER=ses`). `scripts/ses-bootstrap.ts`. Safe to run several. | | `packages/db` | `@coss/db` | Drizzle schema (generated Better Auth tables + domain tables), pg client, TypeIDs, migrations, test guard; `./jobs` (pg-boss); `./idempotency` (`withIdempotencyKey`, request hashes); `./partitions` (daily range partitions: create ahead, drop expired). | | `packages/signals` | `@coss/signals` | Stateless abuse signals shared by coss.com's sign-up and risk: `./email` (syntax, disposable domains, MX/null-MX/NXDOMAIN with a cache, canonical mailbox, role accounts, free providers, typo suggestions), `./ip` (bogons, subnets, CIDR range sets, `IpIntelligence`: Tor, cloud/hosting networks, ASN, country), `./datasets` (parsers for the public IP feeds and IPinfo Lite's MMDB), `./testing` (`buildTestMmdb`). No workspace deps, no database; server-only. | -| `packages/auth` | `@coss/auth` | Better Auth options/instance (organization, admin, api-key, two-factor, passkey, sso, scim, captcha plugins), org permissions, slug rules, API key permissions (`./api-key-permissions`: the labelled catalog, access presets, validation; client-safe), the sign-up email guard (`src/email-guard.ts`, built on `@coss/signals/email`); `./social`: Google/GitHub sign-in (server-only); `./risk`: the injected sign-up risk assessor (coss.com Risk on coss.com's own sign-ups); `./sso`: SSO trust rules; `./scim`: directory sync rules (both server-only); `./emails`: the account email outbox; `./mobile`: the iOS app's sign-in (PKCE codes, bearer sessions; server-only). See `packages/auth/AGENTS.md`. | +| `packages/auth` | `@coss/auth` | Better Auth options/instance (organization, admin, api-key, two-factor, passkey, sso, scim, captcha plugins), org permissions, slug rules, API key permissions (`./api-key-permissions`: the labelled catalog, access presets, validation; client-safe), the sign-up email guard (`src/email-guard.ts`, built on `@coss/signals/email`); `./social`: Google/GitHub sign-in (server-only); `./risk`: the injected sign-up risk assessor (coss.com Risk on coss.com's own sign-ups); `./sso`: SSO trust rules; `./scim`: directory sync rules (both server-only); `./emails`: the account email outbox; `./mobile`: the iOS app's sign-in (PKCE codes, bearer sessions; server-only); `./first-touch`: where a visitor first came from, kept in the browser until sign-up (client-safe). See `packages/auth/AGENTS.md`. | | `packages/tenancy` | `@coss/tenancy` | Projects (`createFirstProject`: a new organization's, made by the dashboard's onboarding), environments, environment-bound API keys (create/authenticate), memberships, organization SSO settings, directory sync (SCIM) connections and group roles, seed/admin scripts, test fixtures; `./logos`: organization logos found on the organization's own website (verified sending domains, then members' work email domains; SSRF-safe HTTPS, sniffed and size-checked images, kept in `organization_logo` and served by the dashboard at `//settings/logo` under a sandboxing CSP), run by the worker's `organization-logos` loop; `./admin`: suspensions, SSO/SCIM recovery, organization deletion, audit log and cross-org reads; `./deletion`: organization teardown (imports only `@coss/db`). See `packages/tenancy/AGENTS.md`. | | `packages/email` | `@coss/email` | Email product: wire schemas, providers (fake, SES under `./ses`), send/dispatch/read, recipients + event ingestion, domains/DNS (with the BIMI check), custom tracking domains (CloudFront distribution tenants), dedicated IP pools (SES managed dedicated IPs, `dedicated-ips.ts`), receiving (received emails, receiving domains, managed inbound addresses, the content store), inboxes for agents (`inboxes.ts`, `inbox-send.ts`: threads, sends and replies; wire schemas in `./inbox-schemas`), DMARC reports (ingestion, guidance to `p=reject`, owner notifications), templates (drafts, versions, locales, rendering at send), tenants, sweeps, contacts (custom properties, audiences, segments, topics, marketing settings; wire schemas in `./contact-schemas`), broadcasts (wire schemas in `./broadcast-schemas`), sub-accounts (email for platforms: a customer's end users with their own domains and SES tenant, suppressions, quotas, reputation and screening; wire schemas in `./subaccount-schemas`); `./templates` (the template language: parse, check, render, variable validation; client-safe, no imports), `./domain-connect` (one-click DNS setup: discovery, the signed apply link, `domain-connect/coss.com.email.json`), `./dispatch` (pg-boss queue + worker handler), `./runtime` builds providers from env, `./admin` (the admin console's cross-org reads), `./env` (zod only), `./testing`. See `packages/email/AGENTS.md`. | | `packages/ratelimit` | `@coss/ratelimit` | Rate limits: bucket catalog, GCRA in Valkey (Lua) or memory, per-org overrides (`rate_limit_override`), client IP resolution; `./env` (zod only), `./testing`. `scripts/valkey-bootstrap.ts`, `scripts/set-override.ts`. | @@ -95,7 +95,7 @@ billing enqueues `billing.*` events through it — and email imports only `./met `db ← logs ← webhooks ← verify` (`verify` also `→ sms, risk, reputation, ratelimit, signals, retention, versioning` and only `@coss/billing/{metering,gate,catalog}`; `email` imports only `@coss/verify/schemas`, for `WEBHOOK_EVENT_TYPES`); `db ← logs ← webhooks ← sign` (`sign` also `→ versioning, retention` and only `@coss/billing/metering`; `email` imports only `@coss/sign/schemas`, for `WEBHOOK_EVENT_TYPES`); `db ← logs ← webhooks ← scheduling` (`scheduling` also `→ notifications, dashboard-routes, email-layout, versioning, retention` and only `@coss/billing/{metering,gate}`; never `@coss/email` or `@coss/sms`: sending is injected; `email` imports only `@coss/scheduling/schemas`, for `WEBHOOK_EVENT_TYPES`); `db ← notifications ← support` (`support` also `→ signals, email-layout, retention` and only `@coss/billing/catalog`; never `@coss/email`: mail, reply text and the plan lookup are injected); `db ← logs ← webhooks ← risk ← forms` (`forms` also `→ versioning, retention` and only `@coss/billing/metering`; never `@coss/email`: mail and contacts are injected; `email` imports only `@coss/forms/schemas`, for `WEBHOOK_EVENT_TYPES`); `api → coss-mcp (its `./http`; `coss` comes with it), tenancy, email, sms, sign, verify, forms, hooks, reputation, webhooks, ratelimit, versioning, logs, billing, risk, screening (`./env` only), db`; `smtp → tenancy, email, ratelimit, logs, billing, observability, db`; -`web → auth, tenancy, email, sms, sign, forms, support, hooks, reputation, screening, webhooks, ratelimit, versioning, retention, logs, billing, risk, push, migrate, companion, db, ui`; `mobile → companion` (types only; a dev dependency), `retention`, `observability`, `dashboard-routes` (bundled by Metro: keep them dependency-free); `portal → hooks, webhooks, ratelimit, logs, observability, db, ui`; `sign-app → sign, ratelimit, observability, db, ui`; `site → ui, auth, versioning, retention, billing, risk, reputation, ratelimit, sms, sdkgen` (only the client-safe `@coss/auth/{slugs,constants,api-key-permissions}`, the changelog, the retention policy, `@coss/billing/{catalog,money,engine}`, `@coss/risk/schemas`, `@coss/reputation/schemas` (the default policy), `@coss/ratelimit/buckets` (the bucket catalog, no imports), `@coss/sms/segments` (the segment calculator) and `@coss/sdkgen`'s `snippets.json` + `./methods`) + +`web → auth, tenancy, email, sms, sign, forms, support, hooks, reputation, screening, webhooks, ratelimit, versioning, retention, logs, billing, risk, push, migrate, companion, db, ui`; `mobile → companion` (types only; a dev dependency), `retention`, `observability`, `dashboard-routes` (bundled by Metro: keep them dependency-free); `portal → hooks, webhooks, ratelimit, logs, observability, db, ui`; `sign-app → sign, ratelimit, observability, db, ui`; `site → ui, auth, versioning, retention, billing, risk, reputation, ratelimit, sms, sdkgen` (only the client-safe `@coss/auth/{slugs,constants,api-key-permissions,first-touch}`, the changelog, the retention policy, `@coss/billing/{catalog,money,engine}`, `@coss/risk/schemas`, `@coss/reputation/schemas` (the default policy), `@coss/ratelimit/buckets` (the bucket catalog, no imports), `@coss/sms/segments` (the segment calculator) and `@coss/sdkgen`'s `snippets.json` + `./methods`) + `@coss/api/openapi.json` (data only, never api code); `sdkgen → @coss/api/openapi.json` (data only); `infra → auth, email, sms, sign, hooks, ratelimit, screening, billing` (`slugs`, `env` only); `worker → auth (`./emails` only), email, sms, sign, verify, forms, support, hooks, reputation, screening, webhooks, ratelimit, logs, risk, push, migrate, tenancy, retention, billing, db` (from tenancy only `./retention`, `./deletion` and `./logos`, which import nothing but `db` and `signals`); `sdk`, `ui`, `kloq` and `companion` depend on no workspace (`site → kloq`, `expo-kloq → kloq`, `expo-kloq-example → expo-kloq`); `mcp` and `cli` depend only on the published `coss` (a semver range, not `workspace:`). @@ -539,7 +539,7 @@ notification emails) and, if there is useful advice, to `ADVICE` (`packages/repu (and the dispatch jobs, from the job's `organizationId`); `@coss/sms/admin` is platform-level like `@coss/email/admin`. Server actions take only slugs + target ids and derive the org from `requireOrgPermission`; every Better Auth organization call passes `organizationId` explicitly (never rely on `session.activeOrganizationId`). -- **Client-safe modules** never import `@coss/db`: `@coss/compliance/catalog`, `@coss/dashboard-routes`, `@coss/risk/{schemas,env}`, `@coss/observability/*`, `@coss/auth/{permissions,slugs,constants,api-key-permissions,org-access}`, `@coss/push/schemas`, `@coss/forms/{schemas,env}`, `@coss/scheduling/{branding,holiday-countries}`, `@coss/email/schemas`, +- **Client-safe modules** never import `@coss/db`: `@coss/compliance/catalog`, `@coss/dashboard-routes`, `@coss/risk/{schemas,env}`, `@coss/observability/*`, `@coss/auth/{permissions,slugs,constants,api-key-permissions,org-access,first-touch}`, `@coss/push/schemas`, `@coss/forms/{schemas,env}`, `@coss/scheduling/{branding,holiday-countries}`, `@coss/email/schemas`, `@coss/email/{broadcast-schemas,contact-schemas,inbox-schemas,subaccount-schemas,test-event-schemas,env,templates}`, `@coss/sms/{schemas,segments,env}`, `@coss/verify/schemas`, `@coss/hooks/{schemas,env}`, `@coss/reputation/schemas`, `@coss/screening/env`, `@coss/webhooks/{schemas,env}`, `@coss/ratelimit/buckets`, `@coss/versioning`, `@coss/retention`, `@coss/logs/{schemas,query}`, `@coss/billing/{catalog,schemas,engine,money,env,gate,wire}` (`gate` is types only), `@coss/ui/*`, `@coss/ui/theme.css`, `@coss/companion`, `@coss/migrate/templates`, `@coss/email-layout`. `@coss/webhooks`, `@coss/db/jobs`, `@coss/screening/{ai,runtime}`, `@coss/email/{runtime,ses,dispatch,domain-connect}`, `@coss/risk/{runtime,datasets}`, `@coss/signals/*`, `@coss/sms` (root, `./twilio`, `./runtime`, `./dispatch`, `./admin`), `@coss/push` (root, `./apns`), `@coss/notifications` and `@coss/billing` (root, `./metering`, `./runtime`, `./stripe`) are server-only; only `./stripe` (and `./runtime`) import the Stripe SDK. Server-only web modules start with `import "server-only"`. - **Rate limits** (`@coss/ratelimit`): buckets are defined only in `BUCKETS` (`src/buckets.ts`); GCRA state is one diff --git a/apps/site/src/app/layout.tsx b/apps/site/src/app/layout.tsx index 2d9dae1a8..af7b3dad1 100644 --- a/apps/site/src/app/layout.tsx +++ b/apps/site/src/app/layout.tsx @@ -2,6 +2,7 @@ import "./global.css"; import { fontMono, fontSans } from "@coss/ui/fonts"; import { RootProvider } from "fumadocs-ui/provider/next"; import type { Metadata, Viewport } from "next"; +import { FirstTouchCapture } from "@/components/first-touch-capture"; import { SITE_DESCRIPTION, SITE_NAME, SITE_URL } from "@/lib/site"; export const metadata: Metadata = { @@ -37,6 +38,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) { {/* Fumadocs' own search dialog stays off: the header's command menu searches instead. */} {children} + ); diff --git a/apps/site/src/components/first-touch-capture.tsx b/apps/site/src/components/first-touch-capture.tsx new file mode 100644 index 000000000..3b5fe64cd --- /dev/null +++ b/apps/site/src/components/first-touch-capture.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { rememberFirstTouch } from "@coss/auth/first-touch"; +import { useEffect } from "react"; + +/** Keeps where a visitor first came from, in their browser, for their sign-up on the dashboard. */ +export function FirstTouchCapture() { + useEffect(() => { + rememberFirstTouch(); + }, []); + return null; +} diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index f9da8a07b..f9bf2fc5f 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -44,6 +44,7 @@ directory unless they name a workspace. `PUBLIC_PATH_PREFIXES` (`src/proxy.ts`). The entry page is `/dashboard` (`DASHBOARD_PATH`), never `/` (the site's landing page): use it for every "go to the dashboard" link and as the post-sign-in default (`safeRedirectPath`). The dashboard has no root files (icon, robots, sitemap): the site serves them. Its pages are noindex (the root layout) except `/sign-in` and `/sign-up` (`publicPageMetadata`, `src/lib/public-metadata.ts`), whose canonical folds the `?next=` links of every other signed-out path into one URL. Signed out, `src/proxy.ts` redirects only the dashboard's own top-level pages and API; any other document request answers 404 with a refresh to sign-in (`src/lib/signed-out.ts`), the same for a real organization and a made-up one, so dead URLs are 404s to search engines and slugs can't be enumerated. +- Sign-up sources: the site and `/sign-up` keep a visitor's first touch in `localStorage` (`@coss/auth/first-touch`: path, referrer host and campaign parameters only; nothing with Global Privacy Control on), `/onboarding` reports it once (`recordSignupAttributionAction`, `signup_attribution`), and `/admin/signups` shows the counts. Invited users never reach onboarding, so they aren't counted. - Domain management uses `emailRuntime().domainDeps` (`src/lib/email-runtime.ts`) and webhook management `webhookManagementDeps()` (`src/lib/webhooks.ts`); domain/webhook errors carry user-facing messages. Revealing a webhook secret needs `webhook:update` (members can read endpoints but never see secrets). diff --git a/apps/web/e2e/smoke.e2e.ts b/apps/web/e2e/smoke.e2e.ts index 0ea2db2b9..8466922c4 100644 --- a/apps/web/e2e/smoke.e2e.ts +++ b/apps/web/e2e/smoke.e2e.ts @@ -67,6 +67,32 @@ test("a new customer goes from sign-up to an API key and a domain", async ({ pag await expect(page.getByText(domain).first()).toBeVisible(); }); +test("a new account remembers where it first came from", async ({ page }) => { + const id = randomUUID().slice(0, 8); + const email = `e2e-${id}@gmail.com`; + + await page.goto(`/sign-up?utm_source=e2e-${id}`); + await signUp(page, email); + await markEmailVerified(email); + await signIn(page, email); + await page.waitForURL("**/onboarding**"); + + // Reported once, then forgotten. + await expect.poll(() => page.evaluate(() => localStorage.getItem("coss.first-touch"))).toBeNull(); + const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + try { + const { rows } = await client.query( + `select a.utm_source as source, a.landing_path as path from signup_attribution a + join "user" u on u.id = a.user_id where u.email = $1`, + [email], + ); + expect(rows).toEqual([{ source: `e2e-${id}`, path: "/sign-up" }]); + } finally { + await client.end(); + } +}); + test("signed-out visitors are sent to sign in", async ({ page }) => { await page.goto("/dashboard"); await page.waitForURL("**/sign-in**"); diff --git a/apps/web/src/app/(auth)/sign-up/sign-up-form.tsx b/apps/web/src/app/(auth)/sign-up/sign-up-form.tsx index 421849017..dacbeee63 100644 --- a/apps/web/src/app/(auth)/sign-up/sign-up-form.tsx +++ b/apps/web/src/app/(auth)/sign-up/sign-up-form.tsx @@ -1,6 +1,7 @@ "use client"; import { DEVICE_TOKEN_HEADER, type SocialProvider } from "@coss/auth/constants"; +import { rememberFirstTouch } from "@coss/auth/first-touch"; import { Button } from "@coss/ui/components/button"; import { Card, CardDescription, CardHeader, CardPanel, CardTitle } from "@coss/ui/components/card"; import { Field, FieldDescription, FieldLabel } from "@coss/ui/components/field"; @@ -8,7 +9,7 @@ import { Input } from "@coss/ui/components/input"; import { Separator } from "@coss/ui/components/separator"; import { collectDevice } from "coss/device"; import Link from "next/link"; -import { type FormEvent, useState } from "react"; +import { type FormEvent, useEffect, useState } from "react"; import { FormError } from "@/components/form-error"; import { SocialSignInButtons } from "@/components/social-sign-in"; import { useTurnstile } from "@/components/turnstile"; @@ -30,6 +31,10 @@ export function SignUpForm({ initialError: string | null; }) { const turnstile = useTurnstile(turnstileSiteKey, "sign-up"); + // For visitors whose first page is this one; the site's pages do the same for the rest. + useEffect(() => { + rememberFirstTouch(); + }, []); const [error, setError] = useState(initialError); const [pending, setPending] = useState(false); const [createdEmail, setCreatedEmail] = useState(null); diff --git a/apps/web/src/app/admin/signups/page.tsx b/apps/web/src/app/admin/signups/page.tsx index bac84bf79..4aa43124f 100644 --- a/apps/web/src/app/admin/signups/page.tsx +++ b/apps/web/src/app/admin/signups/page.tsx @@ -20,6 +20,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { AdminActionDialog } from "@/components/admin/admin-action-dialog"; import { listHref, stringParam } from "@/components/admin/list-controls"; +import { SignupSources } from "@/components/admin/signup-sources"; import { UserStatusBadge } from "@/components/admin/status-badges"; import { CursorPager } from "@/components/cursor-pager"; import { EmptyState } from "@/components/empty-state"; @@ -77,6 +78,7 @@ export default async function AdminSignUpsPage({ searchParams }: PageProps<"/adm return (
+ Sign-ups aren't assessed @@ -148,6 +150,7 @@ export default async function AdminSignUpsPage({ searchParams }: PageProps<"/adm description="Every sign-up as coss.com Risk assessed it, newest first. Blocked sign-ups were refused and have no account; the address, IP and user agent are kept for 90 days. Report abuse here (or take the account down with it) so Risk flags the same mailbox, device and network next time; mark false positives legitimate." title="Sign-ups" /> + ({ value: option.value, diff --git a/apps/web/src/app/onboarding/actions.ts b/apps/web/src/app/onboarding/actions.ts index a4ceaef9f..855e74940 100644 --- a/apps/web/src/app/onboarding/actions.ts +++ b/apps/web/src/app/onboarding/actions.ts @@ -1,11 +1,12 @@ "use server"; -import { createFirstProject } from "@coss/tenancy"; +import { createFirstProject, recordSignupAttribution } from "@coss/tenancy"; import { refresh } from "next/cache"; +import { z } from "zod"; import type { ActionResult } from "@/lib/action-result"; import { formString, runAction } from "@/lib/actions"; import { recordAudit } from "@/lib/audit"; -import { requireOrgPermission } from "@/lib/session"; +import { requireOrgPermission, requireSession } from "@/lib/session"; /** An organization's first project, so a new owner has an environment and API keys at once. */ export async function createFirstProjectAction( @@ -29,3 +30,36 @@ export async function createFirstProjectAction( return null; }); } + +const CAMPAIGN_VALUE = z + .string() + .regex(/^[\w.+ -]{1,100}$/) + .nullable(); + +const FirstTouch = z.strictObject({ + landingPath: z.string().startsWith("/").max(200), + referrerHost: z + .string() + .regex(/^[a-z0-9.-]{1,253}$/) + .nullable(), + utmSource: CAMPAIGN_VALUE, + utmMedium: CAMPAIGN_VALUE, + utmCampaign: CAMPAIGN_VALUE, + landedAt: z.iso.datetime().pipe(z.coerce.date()), +}); + +/** + * What the browser remembers of the visit that led to this account. Written only for the signed-in + * user, never while impersonating them; a value that doesn't parse is simply dropped. + */ +export async function recordSignupAttributionAction(touch: unknown): Promise { + return runAction(async () => { + const session = await requireSession(); + if (session.session.impersonatedBy) return null; + const parsed = FirstTouch.safeParse(touch); + if (parsed.success) { + await recordSignupAttribution({ userId: session.user.id, ...parsed.data }); + } + return null; + }); +} diff --git a/apps/web/src/app/onboarding/page.tsx b/apps/web/src/app/onboarding/page.tsx index 3ebdc1613..9f0c725c2 100644 --- a/apps/web/src/app/onboarding/page.tsx +++ b/apps/web/src/app/onboarding/page.tsx @@ -4,6 +4,7 @@ import { SignOutButton } from "@/components/sign-out-button"; import { safeRedirectPath } from "@/lib/safe-redirect"; import { requireSession } from "@/lib/session"; import { CreateOrganizationForm } from "./create-organization-form"; +import { RecordFirstTouch } from "./record-first-touch"; export const metadata: Metadata = { title: "Create an organization" }; @@ -12,6 +13,7 @@ export default async function OnboardingPage({ searchParams }: PageProps<"/onboa const next = safeRedirectPath((await searchParams).next, "") || null; return ( +
diff --git a/apps/web/src/app/onboarding/record-first-touch.tsx b/apps/web/src/app/onboarding/record-first-touch.tsx new file mode 100644 index 000000000..7d77d3843 --- /dev/null +++ b/apps/web/src/app/onboarding/record-first-touch.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { forgetFirstTouch, recallFirstTouch } from "@coss/auth/first-touch"; +import { useEffect } from "react"; +import { recordSignupAttributionAction } from "./actions"; + +/** Reports the first touch once the visitor has an account. Failing here never affects onboarding. */ +export function RecordFirstTouch() { + useEffect(() => { + const touch = recallFirstTouch(); + if (!touch) return; + recordSignupAttributionAction(touch).then( + (result) => { + if (result.ok) forgetFirstTouch(); + }, + () => {}, + ); + }, []); + return null; +} diff --git a/apps/web/src/components/admin/signup-sources.tsx b/apps/web/src/components/admin/signup-sources.tsx new file mode 100644 index 000000000..49f0611fb --- /dev/null +++ b/apps/web/src/components/admin/signup-sources.tsx @@ -0,0 +1,60 @@ +import { type SignupSourceCount, signupSourcesForAdmin } from "@coss/tenancy"; +import { Panel, PanelBody } from "@/components/panel"; + +const WINDOW_DAYS = 30; + +function SourceList({ + title, + rows, + empty, +}: { + title: string; + rows: SignupSourceCount[]; + /** What a null value means in this list. */ + empty: string; +}) { + return ( +
+

{title}

+ {rows.length === 0 ? ( +

None yet.

+ ) : ( +
    + {rows.map((row) => ( +
  • + + {row.value ?? empty} + + {row.count} +
  • + ))} +
+ )} +
+ ); +} + +/** Where new accounts first came from: the visit their browser remembered. */ +export async function SignupSources() { + const since = new Date(Date.now() - WINDOW_DAYS * 24 * 60 * 60 * 1000); + const sources = await signupSourcesForAdmin({ since }); + return ( + + +
+

Where sign-ups came from

+

+ Last {WINDOW_DAYS} days: {sources.attributed} of {sources.accounts} new accounts have a + first visit on record. The rest were invited, finished on another device, or have Global + Privacy Control on. +

+
+
+ + + +
+
+
+ ); +} diff --git a/packages/auth/package.json b/packages/auth/package.json index 8047ae818..6e1318a23 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -20,7 +20,8 @@ "./risk": "./src/risk.ts", "./mobile": "./src/mobile.ts", "./cli": "./src/cli.ts", - "./org-access": "./src/org-access.ts" + "./org-access": "./src/org-access.ts", + "./first-touch": "./src/first-touch.ts" }, "scripts": { "typecheck": "tsc --noEmit", diff --git a/packages/auth/src/first-touch.ts b/packages/auth/src/first-touch.ts new file mode 100644 index 000000000..279927a57 --- /dev/null +++ b/packages/auth/src/first-touch.ts @@ -0,0 +1,110 @@ +/** + * Where a visitor first came from, kept in their browser until they have an account: the site and + * the dashboard share coss.com's origin, so both can read it. Only the path of the first page, the + * host of the referrer and the campaign parameters, never a full URL. Nothing is stored for + * visitors with Global Privacy Control on. Client-safe, no imports. + */ + +export const FIRST_TOUCH_KEY = "coss.first-touch"; + +/** A visit this old says little about why someone signed up today. */ +const MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000; +const MAX_PATH_LENGTH = 200; +const CAMPAIGN_VALUE = /^[\w.+ -]{1,100}$/; + +export type FirstTouch = { + landingPath: string; + referrerHost: string | null; + utmSource: string | null; + utmMedium: string | null; + utmCampaign: string | null; + /** ISO 8601. */ + landedAt: string; +}; + +function campaignValue(value: string | null): string | null { + return value && CAMPAIGN_VALUE.test(value) ? value : null; +} + +/** A visit's first touch. An internal referrer is no referrer: the visit began somewhere else. */ +export function firstTouchFrom({ + href, + referrer, + now, +}: { + href: string; + referrer: string; + now: Date; +}): FirstTouch { + const url = new URL(href); + let referrerHost: string | null = null; + try { + const host = new URL(referrer).hostname.toLowerCase(); + if (host && host !== url.hostname.toLowerCase()) referrerHost = host; + } catch { + // Empty or not a URL: a direct visit. + } + return { + landingPath: url.pathname.slice(0, MAX_PATH_LENGTH), + referrerHost, + utmSource: campaignValue(url.searchParams.get("utm_source") ?? url.searchParams.get("ref")), + utmMedium: campaignValue(url.searchParams.get("utm_medium")), + utmCampaign: campaignValue(url.searchParams.get("utm_campaign")), + landedAt: now.toISOString(), + }; +} + +/** The stored value, or null if it's missing, malformed or too old. */ +export function parseStoredFirstTouch(stored: string | null, now: Date): FirstTouch | null { + if (!stored) return null; + try { + const touch = JSON.parse(stored) as FirstTouch; + if (typeof touch?.landingPath !== "string" || typeof touch.landedAt !== "string") return null; + const landedAt = Date.parse(touch.landedAt); + if (Number.isNaN(landedAt) || now.getTime() - landedAt > MAX_AGE_MS) return null; + return touch; + } catch { + return null; + } +} + +/** Storage can throw (private windows, blocked site data): none of this may ever break a page. */ +function storage(): Storage | null { + try { + return window.localStorage; + } catch { + return null; + } +} + +/** Keeps this visit unless an earlier one is already kept. Call once per page load, in an effect. */ +export function rememberFirstTouch(): void { + try { + const gpc = (navigator as Navigator & { globalPrivacyControl?: boolean }).globalPrivacyControl; + if (gpc) return; + const store = storage(); + if (!store) return; + const now = new Date(); + if (parseStoredFirstTouch(store.getItem(FIRST_TOUCH_KEY), now)) return; + const touch = firstTouchFrom({ href: location.href, referrer: document.referrer, now }); + store.setItem(FIRST_TOUCH_KEY, JSON.stringify(touch)); + } catch { + // Best effort. + } +} + +export function recallFirstTouch(): FirstTouch | null { + try { + return parseStoredFirstTouch(storage()?.getItem(FIRST_TOUCH_KEY) ?? null, new Date()); + } catch { + return null; + } +} + +export function forgetFirstTouch(): void { + try { + storage()?.removeItem(FIRST_TOUCH_KEY); + } catch { + // Best effort. + } +} diff --git a/packages/auth/test/first-touch.test.ts b/packages/auth/test/first-touch.test.ts new file mode 100644 index 000000000..763c8415e --- /dev/null +++ b/packages/auth/test/first-touch.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import { firstTouchFrom, parseStoredFirstTouch } from "../src/first-touch.ts"; + +const now = new Date("2026-11-05T12:00:00Z"); + +describe("firstTouchFrom", () => { + test("keeps the path and the referrer's host, never the full URLs", () => { + const touch = firstTouchFrom({ + href: "https://coss.com/compare/resend?token=secret#x", + referrer: "https://www.google.com/search?q=resend+alternative", + now, + }); + expect(touch).toEqual({ + landingPath: "/compare/resend", + referrerHost: "www.google.com", + utmSource: null, + utmMedium: null, + utmCampaign: null, + landedAt: "2026-11-05T12:00:00.000Z", + }); + }); + + test("treats an internal or missing referrer as direct", () => { + for (const referrer of ["https://coss.com/ui", "", "not a url"]) { + expect(firstTouchFrom({ href: "https://coss.com/", referrer, now }).referrerHost).toBeNull(); + } + }); + + test("reads campaign parameters, with ref as a source, and drops anything odd", () => { + const utm = firstTouchFrom({ + href: "https://coss.com/?utm_source=hn&utm_medium=social&utm_campaign=launch-1", + referrer: "", + now, + }); + expect([utm.utmSource, utm.utmMedium, utm.utmCampaign]).toEqual(["hn", "social", "launch-1"]); + expect( + firstTouchFrom({ href: "https://coss.com/?ref=producthunt", referrer: "", now }).utmSource, + ).toBe("producthunt"); + expect( + firstTouchFrom({ href: "https://coss.com/?utm_source=