@@ -222,8 +225,9 @@ export function Field({
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
+ disabled={disabled}
aria-describedby={hint ? `${id}-hint` : undefined}
- className="input mt-1 block w-full rounded-lg px-3 py-1.5 text-sm"
+ className="input mt-1 block w-full rounded-lg px-3 py-1.5 text-sm disabled:cursor-not-allowed disabled:opacity-60"
/>
{hint ? (
diff --git a/apps/dashboard/src/demo/mockApi.ts b/apps/dashboard/src/demo/mockApi.ts
index 058142a..ccbe984 100644
--- a/apps/dashboard/src/demo/mockApi.ts
+++ b/apps/dashboard/src/demo/mockApi.ts
@@ -187,6 +187,19 @@ function exportResponse(url: URL): Response {
function route(url: URL, method: string, body: unknown): Response | null {
const p = url.pathname;
+ // The demo has no CRM database, which the real Worker reports before it authenticates anything —
+ // including for a write. Answered ahead of the read-only guard so the CRM tab shows its
+ // "extension not enabled" explanation rather than a 403 that would read as a permissions problem.
+ if (p === '/api/crm' || p.startsWith('/api/crm/')) {
+ return json({ error: 'crm_unavailable' }, 501);
+ }
+
+ // No SESSION_SECRET on a static demo, so there is no operator session to report — the same 503
+ // the real /api/auth routes answer with when account auth is not configured.
+ if (p.startsWith('/api/auth/')) {
+ return json({ error: 'auth_unavailable' }, 503);
+ }
+
// Admin writes are refused: the demo is strictly read-only.
if (method !== 'GET' && p !== '/api/stats/query') {
return json({ error: 'demo_read_only' }, 403);
diff --git a/apps/dashboard/src/hooks/crm.ts b/apps/dashboard/src/hooks/crm.ts
new file mode 100644
index 0000000..53c7721
--- /dev/null
+++ b/apps/dashboard/src/hooks/crm.ts
@@ -0,0 +1,246 @@
+// React Query hooks for the optional CRM extension. Every call goes through `sessionFetch`, which
+// carries the operator session cookie and NO API key — the `/api/crm/*` routes refuse keys by design.
+//
+// Retries: a 501 (no CRM database), a 403 (role too low), a 401 (no session) and a 503 (accounts off)
+// are all facts about the deployment or the operator, not transient failures. Re-asking cannot change
+// any of them, so they fail on the first response and the tab renders its explanation immediately
+// instead of spinning through react-query's default three attempts first.
+
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { sessionFetch } from '../api.js';
+import {
+ type CompanyAnalytics,
+ type ContactAnalytics,
+ type CrmCompany,
+ type CrmContact,
+ crmBlockOf,
+} from '../lib/crm.js';
+
+const retry = (failureCount: number, error: unknown): boolean =>
+ crmBlockOf(error) === null && failureCount < 1;
+
+/** Page size for both list views. The API caps `limit` at 100 and defaults to 25. */
+export const CRM_PAGE_SIZE = 25;
+
+export interface CrmListParams {
+ /** `''` means every status. */
+ status: string;
+ /** Bounded substring search; `''` means no search. */
+ q: string;
+ offset: number;
+}
+
+function listPath(base: string, siteId: string, params: CrmListParams): string {
+ const qs = new URLSearchParams({
+ site_id: siteId,
+ limit: String(CRM_PAGE_SIZE),
+ offset: String(params.offset),
+ });
+ if (params.status) qs.set('status', params.status);
+ const q = params.q.trim();
+ if (q) qs.set('q', q);
+ return `${base}?${qs.toString()}`;
+}
+
+export function useContacts(siteId: string, params: CrmListParams) {
+ return useQuery({
+ queryKey: ['crm', 'contacts', siteId, params],
+ queryFn: () =>
+ sessionFetch<{ contacts: CrmContact[]; total: number; role?: string }>(
+ listPath('/api/crm/contacts', siteId, params),
+ ),
+ enabled: Boolean(siteId),
+ retry,
+ });
+}
+
+export function useContact(siteId: string, id: string) {
+ return useQuery({
+ queryKey: ['crm', 'contact', siteId, id],
+ queryFn: () =>
+ sessionFetch<{ contact: CrmContact }>(`/api/crm/contacts/${id}?site_id=${siteId}`),
+ enabled: Boolean(siteId && id),
+ retry,
+ });
+}
+
+export function useContactAnalytics(siteId: string, id: string) {
+ return useQuery({
+ queryKey: ['crm', 'contact-analytics', siteId, id],
+ queryFn: () =>
+ sessionFetch(`/api/crm/contacts/${id}/analytics?site_id=${siteId}`),
+ enabled: Boolean(siteId && id),
+ retry,
+ });
+}
+
+export function useCompanies(siteId: string, params: CrmListParams) {
+ return useQuery({
+ queryKey: ['crm', 'companies', siteId, params],
+ queryFn: () =>
+ sessionFetch<{ companies: CrmCompany[]; total: number; role?: string }>(
+ listPath('/api/crm/companies', siteId, params),
+ ),
+ enabled: Boolean(siteId),
+ retry,
+ });
+}
+
+/** The API's own ceiling on a CRM page. */
+export const CRM_MAX_PAGE_SIZE = 100;
+
+/**
+ * The companies a contact form can link to. Deliberately a separate query from the paged roster: the
+ * picker needs one flat set, not whatever page the list happens to be showing. It runs only while a
+ * form is mounted, and the caller compares `companies.length` against `total` so a site with more
+ * companies than one page holds says so rather than silently omitting them.
+ */
+export function useCompanyOptions(siteId: string) {
+ return useQuery({
+ queryKey: ['crm', 'company-options', siteId],
+ queryFn: () =>
+ sessionFetch<{ companies: CrmCompany[]; total: number; role?: string }>(
+ `/api/crm/companies?site_id=${siteId}&limit=${CRM_MAX_PAGE_SIZE}&offset=0`,
+ ),
+ enabled: Boolean(siteId),
+ staleTime: 5 * 60 * 1000,
+ retry,
+ });
+}
+
+export function useCompany(siteId: string, id: string) {
+ return useQuery({
+ queryKey: ['crm', 'company', siteId, id],
+ queryFn: () =>
+ sessionFetch<{ company: CrmCompany }>(`/api/crm/companies/${id}?site_id=${siteId}`),
+ enabled: Boolean(siteId && id),
+ retry,
+ });
+}
+
+export function useCompanyContacts(siteId: string, id: string, offset: number) {
+ return useQuery({
+ queryKey: ['crm', 'company-contacts', siteId, id, offset],
+ queryFn: () =>
+ sessionFetch<{ contacts: CrmContact[]; total: number; role?: string }>(
+ `/api/crm/companies/${id}/contacts?site_id=${siteId}&limit=${CRM_PAGE_SIZE}&offset=${offset}`,
+ ),
+ enabled: Boolean(siteId && id),
+ retry,
+ });
+}
+
+export function useCompanyAnalytics(siteId: string, id: string) {
+ return useQuery({
+ queryKey: ['crm', 'company-analytics', siteId, id],
+ queryFn: () =>
+ sessionFetch(`/api/crm/companies/${id}/analytics?site_id=${siteId}`),
+ enabled: Boolean(siteId && id),
+ retry,
+ });
+}
+
+/** Field values a create/update submits. Empty strings are meaningful: the API normalises them to
+ * NULL, which is how a form clears a field it previously set. */
+export type CrmFields = Record;
+
+export function useCreateContact(siteId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (body: CrmFields) =>
+ sessionFetch<{ contact: CrmContact }>(`/api/crm/contacts?site_id=${siteId}`, {
+ method: 'POST',
+ body,
+ }),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['crm', 'contacts', siteId] }),
+ });
+}
+
+export function useUpdateContact(siteId: string, id: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (body: CrmFields) =>
+ sessionFetch<{ contact: CrmContact }>(`/api/crm/contacts/${id}?site_id=${siteId}`, {
+ method: 'PATCH',
+ body,
+ }),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: ['crm', 'contacts', siteId] });
+ void qc.invalidateQueries({ queryKey: ['crm', 'contact', siteId, id] });
+ // The employer may have changed, which moves this person in and out of a company roster.
+ void qc.invalidateQueries({ queryKey: ['crm', 'company-contacts', siteId] });
+ void qc.invalidateQueries({ queryKey: ['crm', 'company-analytics', siteId] });
+ // A changed external_user_id changes what the analytics link resolves to.
+ void qc.invalidateQueries({ queryKey: ['crm', 'contact-analytics', siteId, id] });
+ },
+ });
+}
+
+export function useDeleteContact(siteId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) =>
+ sessionFetch<{ deleted: boolean; consent_records_erased: number }>(
+ `/api/crm/contacts/${id}?site_id=${siteId}`,
+ { method: 'DELETE' },
+ ),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: ['crm', 'contacts', siteId] });
+ void qc.invalidateQueries({ queryKey: ['crm', 'company-contacts', siteId] });
+ void qc.invalidateQueries({ queryKey: ['crm', 'company-analytics', siteId] });
+ },
+ });
+}
+
+export function useCreateCompany(siteId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (body: CrmFields) =>
+ sessionFetch<{ company: CrmCompany }>(`/api/crm/companies?site_id=${siteId}`, {
+ method: 'POST',
+ body,
+ }),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: ['crm', 'companies', siteId] });
+ // The contact form's company picker reads its own query; without this a company created
+ // here is missing from the picker until the cache expires.
+ void qc.invalidateQueries({ queryKey: ['crm', 'company-options', siteId] });
+ },
+ });
+}
+
+export function useUpdateCompany(siteId: string, id: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (body: CrmFields) =>
+ sessionFetch<{ company: CrmCompany }>(`/api/crm/companies/${id}?site_id=${siteId}`, {
+ method: 'PATCH',
+ body,
+ }),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: ['crm', 'companies', siteId] });
+ void qc.invalidateQueries({ queryKey: ['crm', 'company', siteId, id] });
+ void qc.invalidateQueries({ queryKey: ['crm', 'company-options', siteId] });
+ // A renamed company is the resolved `company` label on every one of its contacts.
+ void qc.invalidateQueries({ queryKey: ['crm', 'contacts', siteId] });
+ },
+ });
+}
+
+/** Delete a company. Its contacts survive — the API unlinks them and reports how many. */
+export function useDeleteCompany(siteId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) =>
+ sessionFetch<{ deleted: boolean; contacts_unlinked: number }>(
+ `/api/crm/companies/${id}?site_id=${siteId}`,
+ { method: 'DELETE' },
+ ),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: ['crm', 'companies', siteId] });
+ void qc.invalidateQueries({ queryKey: ['crm', 'company-options', siteId] });
+ // Every unlinked contact's `company` and `company_id` changed.
+ void qc.invalidateQueries({ queryKey: ['crm', 'contacts', siteId] });
+ },
+ });
+}
diff --git a/apps/dashboard/src/lib/crm.ts b/apps/dashboard/src/lib/crm.ts
new file mode 100644
index 0000000..c8ceadf
--- /dev/null
+++ b/apps/dashboard/src/lib/crm.ts
@@ -0,0 +1,155 @@
+// Wire types and access rules for the optional CRM extension. The row shapes mirror the CRM
+// database columns exactly (snake_case, unix-ms timestamps) rather than remapping them, matching how
+// every other read in this app parses the API's own field names.
+//
+// Two classifications live here because the CRM tab's whole behaviour turns on them:
+//
+// • WHY A REQUEST FAILED. A 501 is not an error the reader did anything about — it is the DEFAULT
+// state of a deployment that never bound `CRM_DB`, and it must read as "this feature is not
+// installed", never as a failure. A 403 is a role fact, a 401 is a missing session, and a 503 is
+// a deployment with accounts switched off entirely. Each has a different sentence and none of
+// them is retryable, so they are separated from the transient failures that are.
+//
+// • WHETHER THE OPERATOR MAY DESTROY OR EXPORT. See `canAdministerCrm`.
+
+import type { CompanyStatus, ContactStatus } from '@facet/shared';
+
+/** A person in the CRM, exactly as `GET /api/crm/contacts` returns them. */
+export interface CrmContact {
+ id: string;
+ site_id: string;
+ external_user_id: string | null;
+ email: string | null;
+ name: string | null;
+ phone: string | null;
+ /** The RESOLVED employer: the linked company's name, or the free text typed for an unlinked one. */
+ company: string | null;
+ /** Set when `company` came from a linked `companies` row. Null when `company` is free text. */
+ company_id: string | null;
+ title: string | null;
+ /** Stored as free text in SQLite, so an unexpected value must render rather than crash. */
+ status: string;
+ source: string | null;
+ notes: string | null;
+ owner_user_id: string | null;
+ created_at: number;
+ updated_at: number;
+}
+
+/** An organization, as `GET /api/crm/companies` returns them. */
+export interface CrmCompany {
+ id: string;
+ site_id: string;
+ name: string;
+ domain: string | null;
+ status: string;
+ notes: string | null;
+ owner_user_id: string | null;
+ created_at: number;
+ updated_at: number;
+}
+
+/** The activity summary shared by the contact and company analytics responses. */
+export interface CrmActivity {
+ pageviews: number;
+ /** Custom (named) events only — NOT the total, which counts pageviews too. */
+ events: number;
+ total: number;
+ first_seen: number | null;
+ last_seen: number | null;
+ top_paths: { path: string; views: number }[];
+}
+
+/**
+ * One contact's analytics. `linked: false` is a first-class answer, not an empty success: it means
+ * nothing authorizes connecting this person to any events, which is a different claim from "this
+ * person did nothing" and must never be rendered as zeroes.
+ */
+export type ContactAnalytics =
+ | { linked: false; reason: string }
+ | { linked: true; windows: number; activity: CrmActivity };
+
+/** The contact counts every company rollup carries, linked or not — the denominator. */
+export interface CompanyRollupCounts {
+ contacts_total: number;
+ contacts_linked: number;
+ contacts_considered: number;
+ contacts_truncated: boolean;
+ contacts_limit: number;
+}
+
+export type CompanyAnalytics = CompanyRollupCounts &
+ (
+ | { linked: false; reason: string }
+ | { linked: true; visitor_hashes: number; activity: CrmActivity }
+ );
+
+/** The reason a CRM request cannot succeed, in a form the UI can turn into one specific sentence. */
+export type CrmBlock = 'unavailable' | 'accounts-off' | 'signed-out' | 'forbidden';
+
+/** Classify a CRM/session failure. Returns null for anything transient (which IS worth retrying). */
+export function crmBlockOf(error: unknown): CrmBlock | null {
+ if (!(error instanceof Error)) return null;
+ switch (error.message) {
+ case 'crm_unavailable':
+ return 'unavailable';
+ case 'auth_unavailable':
+ return 'accounts-off';
+ case 'unauthorized':
+ case 'unauthenticated':
+ return 'signed-out';
+ case 'forbidden':
+ return 'forbidden';
+ default:
+ return null;
+ }
+}
+
+/** A team role, ordered. Mirrors `Role`/`ROLE_RANK` in the Worker's accounts library. */
+export type TeamRole = 'owner' | 'admin' | 'analyst' | 'viewer';
+
+const ROLE_RANK: Record = {
+ viewer: 0,
+ analyst: 1,
+ admin: 2,
+ owner: 3,
+};
+
+function isTeamRole(value: string): value is TeamRole {
+ return value in ROLE_RANK;
+}
+
+/**
+ * May this operator delete or export CRM records — the `admin` gate on the two irreversible and
+ * bulk-disclosure routes?
+ *
+ * Answered from the role the SERVER reports on each list response, which is the exact role it
+ * resolved to authorize that request. The browser cannot derive this for itself: `/api/auth/me`
+ * reports a role per team, and no session-reachable route says which team owns the selected site, so
+ * anything computed here would be a guess. Undefined means not yet known, which reads as "no" — the
+ * button appears once the answer arrives rather than flickering out when it does.
+ */
+export function canAdministerCrm(role: string | undefined): boolean {
+ return role !== undefined && isTeamRole(role) && ROLE_RANK[role] >= ROLE_RANK.admin;
+}
+
+/** The closed status sets, for the form controls. Declared once so both forms stay in step. */
+export const CONTACT_STATUSES: ContactStatus[] = ['lead', 'active', 'archived'];
+export const COMPANY_STATUSES: CompanyStatus[] = ['lead', 'active', 'archived'];
+
+/** Prose for a `linked: false` reason code. Unknown codes fall back to the code itself rather than
+ * to silence — a reason the reader cannot see is worse than an unfamiliar one. */
+export function linkReasonText(reason: string): string {
+ switch (reason) {
+ case 'no_external_user_id':
+ return 'This contact has no external user id, so there is nothing to match against a consent record. Add the id your site passes to Facet for this person.';
+ case 'no_active_consent':
+ return 'No active signed consent record authorizes linking this person to analytics. Either they never gave identified consent, or the record has since been revoked or purged by retention.';
+ case 'no_linked_contacts':
+ return 'No contact at this company has an active signed consent record authorizing a link to analytics.';
+ case 'none_linked_within_cap':
+ return 'None of the contacts examined has an active signed consent record. This company has more contacts than one rollup resolves, so older ones were not checked and may well be linked.';
+ default:
+ return `The API gave the reason code "${reason}".`;
+ }
+}
diff --git a/apps/dashboard/src/lib/download.ts b/apps/dashboard/src/lib/download.ts
index e902792..7049b68 100644
--- a/apps/dashboard/src/lib/download.ts
+++ b/apps/dashboard/src/lib/download.ts
@@ -41,6 +41,21 @@ function filenameFor(params: ExportParams): string {
return `facet-${what}-${day}.${params.format}`;
}
+/** Save a fetched blob as a file by clicking a transient object-URL anchor. */
+function saveBlob(blob: Blob, filename: string): void {
+ const url = URL.createObjectURL(blob);
+ try {
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = filename;
+ document.body.appendChild(anchor);
+ anchor.click();
+ anchor.remove();
+ } finally {
+ URL.revokeObjectURL(url);
+ }
+}
+
/**
* Download an export as a file. Fetches with the bearer key, materializes a blob, and clicks a
* transient object-URL anchor. Throws on a non-2xx response so callers can surface an error.
@@ -53,16 +68,22 @@ export async function downloadExport(apiKey: string, params: ExportParams): Prom
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error ?? 'export_failed');
}
- const blob = await res.blob();
- const url = URL.createObjectURL(blob);
- try {
- const anchor = document.createElement('a');
- anchor.href = url;
- anchor.download = filenameFor(params);
- document.body.appendChild(anchor);
- anchor.click();
- anchor.remove();
- } finally {
- URL.revokeObjectURL(url);
+ saveBlob(await res.blob(), filenameFor(params));
+}
+
+/**
+ * Download one contact's data-subject export. Session-authenticated and `admin`-only, so it carries
+ * the cookie and NO bearer key — see `sessionFetch`. A plain link would work for the cookie but
+ * could not surface the error body, and this route's failures (403, 501) are exactly the ones the
+ * operator needs named.
+ */
+export async function downloadContactExport(siteId: string, contactId: string): Promise {
+ const res = await fetch(`/api/crm/contacts/${contactId}/export?site_id=${siteId}`, {
+ credentials: 'same-origin',
+ });
+ if (!res.ok) {
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
+ throw new Error(body.error ?? 'export_failed');
}
+ saveBlob(await res.blob(), `facet-contact-${contactId}.json`);
}
diff --git a/apps/dashboard/src/lib/segment.ts b/apps/dashboard/src/lib/segment.ts
index e3b5e93..a619679 100644
--- a/apps/dashboard/src/lib/segment.ts
+++ b/apps/dashboard/src/lib/segment.ts
@@ -189,6 +189,7 @@ export type SegmentTab =
| 'retention'
| 'experiments'
| 'anomalies'
+ | 'crm'
| 'ask';
/**
@@ -218,6 +219,9 @@ export interface TabSegmentSupport {
* /api/funnels/:id/report and /api/stats/conversions → no dimension params at all. NO filter.
* /api/stats/experiment → experimentResult() scopes by siteId + range only. NO filter.
* /api/stats/query → the executor is handed { siteId, start, end }. NO filter.
+ * /api/crm/* → a contact's activity is every event its consent-verified visitor hashes
+ * produced; contactActivity() takes siteId + hashes and nothing else, and
+ * there is no date range either. NO filter.
*
* A tab whose level is not `full` must render this note next to its numbers. Showing filtered
* labels over unfiltered numbers is the one outcome worse than not filtering at all.
@@ -247,6 +251,10 @@ export const TAB_SEGMENT_SUPPORT: Record = {
level: 'none',
note: 'Not applied. Detection scores site-wide hourly pageviews against their own baseline, so the anomalies below cover all traffic.',
},
+ crm: {
+ level: 'none',
+ note: "Not applied, and there is no date range either. A contact's activity is every event Facet is allowed to attribute to them, for as long as their consent record reaches back.",
+ },
ask: {
level: 'none',
note: 'Not applied. Questions are answered over the whole site for the chosen window, so the answer below covers all traffic.',
diff --git a/apps/dashboard/src/test/a11y.test.tsx b/apps/dashboard/src/test/a11y.test.tsx
index 186f2b6..58f7c11 100644
--- a/apps/dashboard/src/test/a11y.test.tsx
+++ b/apps/dashboard/src/test/a11y.test.tsx
@@ -72,7 +72,7 @@ describe('document structure', () => {
it('gives every view exactly one h1, naming the view', async () => {
seedProfiles();
wrap();
- await waitFor(() => expect(screen.getAllByRole('tab').length).toBe(9));
+ await waitFor(() => expect(screen.getAllByRole('tab').length).toBe(10));
const h1s = () => document.querySelectorAll('h1');
expect(h1s()).toHaveLength(1);
@@ -118,7 +118,7 @@ describe('view tablist', () => {
it('wires each tab to the panel it selects', async () => {
seedProfiles();
wrap();
- await waitFor(() => expect(screen.getAllByRole('tab').length).toBe(9));
+ await waitFor(() => expect(screen.getAllByRole('tab').length).toBe(10));
const selected = screen.getByRole('tab', { selected: true });
const panel = screen.getByRole('tabpanel');
@@ -126,20 +126,20 @@ describe('view tablist', () => {
expect(panel).toHaveAttribute('aria-labelledby', selected.id);
});
- it('is a single tab stop with roving tabindex, not nine', async () => {
+ it('is a single tab stop with roving tabindex, not ten', async () => {
seedProfiles();
wrap();
- await waitFor(() => expect(screen.getAllByRole('tab').length).toBe(9));
+ await waitFor(() => expect(screen.getAllByRole('tab').length).toBe(10));
const tabs = screen.getAllByRole('tab');
expect(tabs.filter((t) => t.tabIndex === 0)).toHaveLength(1);
- expect(tabs.filter((t) => t.tabIndex === -1)).toHaveLength(8);
+ expect(tabs.filter((t) => t.tabIndex === -1)).toHaveLength(9);
});
it('moves selection with Left/Right/Home/End, as role=tablist promises', async () => {
seedProfiles();
wrap();
- await waitFor(() => expect(screen.getAllByRole('tab').length).toBe(9));
+ await waitFor(() => expect(screen.getAllByRole('tab').length).toBe(10));
fireEvent.keyDown(screen.getByRole('tab', { name: 'Overview' }), { key: 'ArrowRight' });
await waitFor(() =>
diff --git a/apps/dashboard/src/test/crm.test.tsx b/apps/dashboard/src/test/crm.test.tsx
new file mode 100644
index 0000000..89b287b
--- /dev/null
+++ b/apps/dashboard/src/test/crm.test.tsx
@@ -0,0 +1,331 @@
+// CRM tab. The four behaviours here are correctness, not polish:
+// 1. A deployment with no CRM database (501) explains itself instead of erroring — that is the
+// DEFAULT state, so an alert or a crash there would be the common experience, not the rare one.
+// 2. A contact with no consent-authorized link renders as NOT LINKED with its reason. Zeroes would
+// assert "this person did nothing", which is a different and false claim.
+// 3. A company rollup always states its denominator: "1 of 12 contacts linked", plus a lower-bound
+// warning when the API truncated the fan-out.
+// 4. Delete and export appear only for an operator whose role provably includes `admin`, and the
+// contact confirmation says that erasure also destroys consent records.
+
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { Crm } from '../components/Crm.js';
+
+const SITE = '11111111-1111-4111-8111-111111111111';
+
+const CONTACT = {
+ id: 'c1',
+ site_id: SITE,
+ external_user_id: null,
+ email: 'ada@example.com',
+ name: 'Ada Lovelace',
+ phone: null,
+ company: 'Acme Inc',
+ company_id: 'co1',
+ title: 'Engineer',
+ status: 'active',
+ source: null,
+ notes: null,
+ owner_user_id: null,
+ created_at: 1_700_000_000_000,
+ updated_at: 1_700_000_000_000,
+};
+
+const COMPANY = {
+ id: 'co1',
+ site_id: SITE,
+ name: 'Acme Inc',
+ domain: 'acme.com',
+ status: 'active',
+ notes: null,
+ owner_user_id: null,
+ created_at: 1_700_000_000_000,
+ updated_at: 1_700_000_000_000,
+};
+
+interface Handlers {
+ /** The role the server reports on each list response — the only authoritative source, since no
+ * session-reachable route maps a site to its owning team. Undefined means the field is absent,
+ * which must read as "not an admin" rather than as permission. */
+ role?: string;
+ contactAnalytics?: unknown;
+ companyAnalytics?: unknown;
+}
+
+/** Every /api/* response the CRM tab can ask for, unless `unavailable` short-circuits them all. */
+function mockApi(handlers: Handlers & { unavailable?: boolean } = {}): void {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = typeof input === 'string' ? input : String(input);
+ if (handlers.unavailable && url.startsWith('/api/crm')) {
+ return { ok: false, status: 501, json: async () => ({ error: 'crm_unavailable' }) };
+ }
+ if (url.includes('/analytics')) {
+ const body = url.includes('/companies/')
+ ? handlers.companyAnalytics
+ : handlers.contactAnalytics;
+ return { ok: true, status: 200, json: async () => body ?? {} };
+ }
+ if (url.startsWith('/api/crm/companies/') && url.includes('/contacts')) {
+ return { ok: true, status: 200, json: async () => ({ contacts: [], total: 0 }) };
+ }
+ if (url.startsWith('/api/crm/companies/')) {
+ return { ok: true, status: 200, json: async () => ({ company: COMPANY }) };
+ }
+ if (url.startsWith('/api/crm/companies')) {
+ return {
+ ok: true,
+ status: 200,
+ json: async () => ({ companies: [COMPANY], total: 1, role: handlers.role }),
+ };
+ }
+ if (url.startsWith('/api/crm/contacts/')) {
+ return { ok: true, status: 200, json: async () => ({ contact: CONTACT }) };
+ }
+ return {
+ ok: true,
+ status: 200,
+ json: async () => ({ contacts: [CONTACT], total: 1, role: handlers.role }),
+ };
+ }),
+ );
+}
+
+function renderCrm() {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+ ,
+ );
+}
+
+/** Open the contact detail pane, which is where the analytics and the admin controls live. */
+async function openContact(): Promise {
+ fireEvent.click(await screen.findByRole('button', { name: /Ada Lovelace/ }));
+ await screen.findByRole('heading', { name: 'Analytics' });
+}
+
+async function openCompany(): Promise {
+ fireEvent.click(screen.getByRole('tab', { name: 'Companies' }));
+ fireEvent.click(await screen.findByRole('button', { name: 'Acme Inc' }));
+ await screen.findByRole('heading', { name: 'Analytics rollup' });
+}
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('CRM tab without a CRM database', () => {
+ it('explains the 501 instead of rendering an error', async () => {
+ mockApi({ unavailable: true });
+ renderCrm();
+
+ expect(
+ await screen.findByText(/CRM extension is not enabled on this deployment/i),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/CRM_DB/)).toBeInTheDocument();
+ // Not an error and not retryable: nothing about an unbound database changes on a second try.
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /Retry/ })).not.toBeInTheDocument();
+ });
+
+ it('says the same thing on the companies section', async () => {
+ mockApi({ unavailable: true });
+ renderCrm();
+ fireEvent.click(await screen.findByRole('tab', { name: 'Companies' }));
+ expect(
+ await screen.findByText(/CRM extension is not enabled on this deployment/i),
+ ).toBeInTheDocument();
+ });
+});
+
+describe('contact analytics link', () => {
+ it('renders an unlinked contact as not linked, with the reason and no zeroes', async () => {
+ mockApi({
+ role: 'analyst',
+ contactAnalytics: { linked: false, reason: 'no_active_consent' },
+ });
+ renderCrm();
+ await openContact();
+
+ expect(await screen.findByText('Not linked to analytics')).toBeInTheDocument();
+ expect(screen.getByText(/No active signed consent record/i)).toBeInTheDocument();
+ expect(screen.getByText(/a report of zero activity/i)).toBeInTheDocument();
+ // The figures must be absent entirely — a 0 next to "Pageviews" is the false claim.
+ expect(screen.queryByText('Pageviews')).not.toBeInTheDocument();
+ expect(screen.queryByText('Custom events')).not.toBeInTheDocument();
+ });
+
+ it('names a missing external user id as the reason when that is the cause', async () => {
+ mockApi({
+ role: 'analyst',
+ contactAnalytics: { linked: false, reason: 'no_external_user_id' },
+ });
+ renderCrm();
+ await openContact();
+
+ expect(await screen.findByText(/no external user id/i)).toBeInTheDocument();
+ });
+
+ it('shows the figures when a link is authorized', async () => {
+ mockApi({
+ role: 'analyst',
+ contactAnalytics: {
+ linked: true,
+ windows: 2,
+ activity: {
+ pageviews: 42,
+ events: 3,
+ total: 45,
+ first_seen: 1_700_000_000_000,
+ last_seen: 1_700_000_100_000,
+ top_paths: [{ path: '/pricing', views: 12 }],
+ },
+ },
+ });
+ renderCrm();
+ await openContact();
+
+ expect(await screen.findByText('42')).toBeInTheDocument();
+ expect(screen.getByText('/pricing')).toBeInTheDocument();
+ expect(screen.queryByText('Not linked to analytics')).not.toBeInTheDocument();
+ });
+});
+
+describe('company rollup', () => {
+ it('states the denominator, so a partial rollup is never read as the whole account', async () => {
+ mockApi({
+ role: 'analyst',
+ companyAnalytics: {
+ contacts_total: 12,
+ contacts_linked: 1,
+ contacts_considered: 12,
+ contacts_truncated: false,
+ contacts_limit: 100,
+ linked: true,
+ visitor_hashes: 2,
+ activity: {
+ pageviews: 142,
+ events: 3,
+ total: 145,
+ first_seen: 1_700_000_000_000,
+ last_seen: 1_700_000_100_000,
+ top_paths: [],
+ },
+ },
+ });
+ renderCrm();
+ await openCompany();
+
+ expect(await screen.findByText('1 of 12 contacts linked')).toBeInTheDocument();
+ expect(screen.getByText(/not this company.s whole traffic/i)).toBeInTheDocument();
+ expect(screen.getByText('142')).toBeInTheDocument();
+ });
+
+ it('flags a truncated rollup as a lower bound', async () => {
+ mockApi({
+ role: 'analyst',
+ companyAnalytics: {
+ contacts_total: 340,
+ contacts_linked: 4,
+ contacts_considered: 100,
+ contacts_truncated: true,
+ contacts_limit: 100,
+ linked: false,
+ reason: 'no_linked_contacts',
+ },
+ });
+ renderCrm();
+ await openCompany();
+
+ expect(await screen.findByText('4 of 340 contacts linked')).toBeInTheDocument();
+ expect(screen.getByText(/Lower bound, not a total/i)).toBeInTheDocument();
+ // Still not zeroes: an unlinked rollup says so rather than reporting no traffic.
+ expect(screen.getByText('Nothing here is linked to analytics')).toBeInTheDocument();
+ });
+});
+
+describe('role-gated actions', () => {
+ it('offers delete and export to an operator who provably holds admin', async () => {
+ mockApi({
+ role: 'admin',
+ contactAnalytics: { linked: false, reason: 'no_active_consent' },
+ });
+ renderCrm();
+ await openContact();
+
+ const remove = await screen.findByRole('button', { name: 'Delete contact' });
+ expect(screen.getByRole('button', { name: /Export this person/ })).toBeInTheDocument();
+
+ // Confirming must state that consent records go too — the part an operator cannot guess.
+ fireEvent.click(remove);
+ expect(await screen.findByRole('alert')).toHaveTextContent(/consent records/i);
+ expect(screen.getByRole('alert')).toHaveTextContent(/cannot be undone/i);
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+ });
+
+ it('hides them from an analyst and says which role they need', async () => {
+ mockApi({
+ role: 'analyst',
+ contactAnalytics: { linked: false, reason: 'no_active_consent' },
+ });
+ renderCrm();
+ await openContact();
+
+ expect(screen.queryByRole('button', { name: 'Delete contact' })).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: /Export this person/ }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText(/need the/i)).toHaveTextContent(/admin/i);
+ });
+
+ it('hides them when the server did not say what the role is', async () => {
+ // An absent `role` is not permission. It reads as "not yet known", so the destructive action
+ // stays hidden rather than being offered on a guess that would answer 403.
+ mockApi({
+ role: undefined,
+ contactAnalytics: { linked: false, reason: 'no_active_consent' },
+ });
+ renderCrm();
+ await openContact();
+
+ expect(screen.queryByRole('button', { name: 'Delete contact' })).not.toBeInTheDocument();
+ });
+
+ it('hides the company delete from a non-admin and offers it to an admin', async () => {
+ const analytics = {
+ contacts_total: 0,
+ contacts_linked: 0,
+ contacts_considered: 0,
+ contacts_truncated: false,
+ contacts_limit: 100,
+ linked: false,
+ reason: 'no_linked_contacts',
+ };
+ mockApi({ role: 'analyst', companyAnalytics: analytics });
+ const { unmount } = renderCrm();
+ await openCompany();
+ expect(screen.queryByRole('button', { name: 'Delete company' })).not.toBeInTheDocument();
+ unmount();
+
+ mockApi({ role: 'owner', companyAnalytics: analytics });
+ renderCrm();
+ await openCompany();
+ fireEvent.click(await screen.findByRole('button', { name: 'Delete company' }));
+ // Deleting a company must not read as deleting the people in it.
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(/are kept/i));
+ expect(screen.getByRole('alert')).toHaveTextContent(/unlinked/i);
+ });
+
+ it('treats a signed-out operator as having no admin capability', async () => {
+ mockApi({ contactAnalytics: { linked: false, reason: 'no_active_consent' } });
+ renderCrm();
+ await openContact();
+
+ expect(screen.queryByRole('button', { name: 'Delete contact' })).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/dashboard/src/test/segment.test.tsx b/apps/dashboard/src/test/segment.test.tsx
index 7ed6b0a..6c3e348 100644
--- a/apps/dashboard/src/test/segment.test.tsx
+++ b/apps/dashboard/src/test/segment.test.tsx
@@ -152,6 +152,7 @@ describe('segment capability claims match the server', () => {
expect(Object.keys(TAB_SEGMENT_SUPPORT).sort()).toEqual([
'anomalies',
'ask',
+ 'crm',
'experiments',
'funnels',
'overview',
diff --git a/apps/server/src/db/contact-analytics.ts b/apps/server/src/db/contact-analytics.ts
index 0eda6f8..7a86f38 100644
--- a/apps/server/src/db/contact-analytics.ts
+++ b/apps/server/src/db/contact-analytics.ts
@@ -10,6 +10,7 @@
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
import type { Env } from '../env.js';
+import { chunked } from '../lib/constants.js';
import { db } from './queries.js';
import * as schema from './schema.js';
import { eventCount, pageviewCount } from './stats.js';
@@ -56,49 +57,80 @@ export async function contactActivity(
visitorHashes: string[],
): Promise {
if (visitorHashes.length === 0) return EMPTY;
- const where = and(
- eq(schema.events.siteId, siteId),
- inArray(schema.events.visitorHash, visitorHashes),
- );
const client = db(env);
- const [totals, paths] = await Promise.all([
- client
- // The SAME expressions /api/stats uses, imported rather than rewritten. A pageview is
- // `name IS NULL` in this schema, so a hand-rolled `name = 'pageview'` would silently
- // report zero pageviews for every real visitor — and the two surfaces would disagree
- // about one person's numbers while agreeing about everyone's.
- .select({
- total: sql`count(*)`,
- pageviews: pageviewCount,
- events: eventCount,
- first_seen: sql`min(${schema.events.createdAt})`,
- last_seen: sql`max(${schema.events.createdAt})`,
- })
- .from(schema.events)
- .where(where)
- .get(),
- client
- .select({
- path: schema.events.path,
- views: sql`count(*)`,
- })
- .from(schema.events)
- .where(where)
- .groupBy(schema.events.path)
- .orderBy(desc(sql`count(*)`))
- .limit(TOP_PATHS),
- ]);
- if (!totals || totals.total === 0) return EMPTY;
+ const summed = { ...EMPTY, top_paths: [] as { path: string; views: number }[] };
+ const pathViews = new Map();
+ // One statement per chunk: an `IN (...)` list is one bound parameter per hash, and D1 refuses a
+ // query with more than 100 of them. A company rollup unions every linked contact's live salt
+ // windows, so this list is contacts x windows and routinely passes that on a real account.
+ for (const batch of chunked(visitorHashes)) {
+ const where = and(
+ eq(schema.events.siteId, siteId),
+ inArray(schema.events.visitorHash, batch),
+ );
+ const [totals, paths] = await Promise.all([
+ client
+ // The SAME expressions /api/stats uses, imported rather than rewritten. A pageview is
+ // `name IS NULL` in this schema, so a hand-rolled `name = 'pageview'` would silently
+ // report zero pageviews for every real visitor — and the two surfaces would disagree
+ // about one person's numbers while agreeing about everyone's.
+ .select({
+ total: sql`count(*)`,
+ pageviews: pageviewCount,
+ events: eventCount,
+ first_seen: sql`min(${schema.events.createdAt})`,
+ last_seen: sql`max(${schema.events.createdAt})`,
+ })
+ .from(schema.events)
+ .where(where)
+ .get(),
+ // Deliberately NOT `LIMIT TOP_PATHS` per chunk. A path in the overall top ten need not be
+ // in any single chunk's top ten, so taking a prefix here and merging would return a
+ // plausible, subtly wrong ranking. Grouping fully and ranking once at the end is exact,
+ // and a visitor set's distinct paths are bounded by the site's own routes.
+ client
+ .select({
+ path: schema.events.path,
+ views: sql`count(*)`,
+ })
+ .from(schema.events)
+ .where(where)
+ .groupBy(schema.events.path),
+ ]);
+ if (!totals) continue;
+ summed.total += totals.total ?? 0;
+ summed.pageviews += totals.pageviews ?? 0;
+ summed.events += totals.events ?? 0;
+ // A hash appears in exactly one chunk, so counts add and the extremes are the extremes.
+ summed.first_seen = minDefined(summed.first_seen, totals.first_seen);
+ summed.last_seen = maxDefined(summed.last_seen, totals.last_seen);
+ for (const row of paths) {
+ pathViews.set(row.path, (pathViews.get(row.path) ?? 0) + row.views);
+ }
+ }
+ if (summed.total === 0) return EMPTY;
return {
- pageviews: totals.pageviews ?? 0,
- events: totals.events ?? 0,
- total: totals.total,
- first_seen: totals.first_seen ?? null,
- last_seen: totals.last_seen ?? null,
- top_paths: paths,
+ ...summed,
+ top_paths: [...pathViews]
+ .map(([path, views]) => ({ path, views }))
+ .sort((a, b) => b.views - a.views || a.path.localeCompare(b.path))
+ .slice(0, TOP_PATHS),
};
}
+/** `Math.min` over values that may be absent, where absent means "no opinion" rather than zero. */
+function minDefined(a: number | null, b: number | null | undefined): number | null {
+ if (a === null || a === undefined) return b ?? null;
+ if (b === null || b === undefined) return a;
+ return Math.min(a, b);
+}
+
+function maxDefined(a: number | null, b: number | null | undefined): number | null {
+ if (a === null || a === undefined) return b ?? null;
+ if (b === null || b === undefined) return a;
+ return Math.max(a, b);
+}
+
/** One event row as it appears in a data-subject export. */
export interface ContactEvent {
created_at: number;
@@ -119,26 +151,35 @@ export async function contactEvents(
visitorHashes: string[],
): Promise {
if (visitorHashes.length === 0) return [];
- return db(env)
- .select({
- created_at: schema.events.createdAt,
- hostname: schema.events.hostname,
- path: schema.events.path,
- referrer: schema.events.referrer,
- name: schema.events.name,
- country: schema.events.country,
- device: schema.events.device,
- channel: schema.events.channel,
- })
- .from(schema.events)
- .where(
- and(
- eq(schema.events.siteId, siteId),
- inArray(schema.events.visitorHash, visitorHashes),
- ),
- )
- .orderBy(desc(schema.events.createdAt))
- .limit(CONTACT_EXPORT_MAX_EVENTS);
+ const client = db(env);
+ const collected: ContactEvent[] = [];
+ // Chunked for D1's bound-parameter limit, as in `contactActivity`. Each chunk takes the full cap
+ // rather than a share of it: the newest `CONTACT_EXPORT_MAX_EVENTS` overall could all belong to
+ // one chunk, so a per-chunk share would drop rows that belong in the export and the caller's
+ // truncation flag would be computed over the wrong set.
+ for (const batch of chunked(visitorHashes)) {
+ const rows = await client
+ .select({
+ created_at: schema.events.createdAt,
+ hostname: schema.events.hostname,
+ path: schema.events.path,
+ referrer: schema.events.referrer,
+ name: schema.events.name,
+ country: schema.events.country,
+ device: schema.events.device,
+ channel: schema.events.channel,
+ })
+ .from(schema.events)
+ .where(and(eq(schema.events.siteId, siteId), inArray(schema.events.visitorHash, batch)))
+ .orderBy(desc(schema.events.createdAt))
+ .limit(CONTACT_EXPORT_MAX_EVENTS);
+ collected.push(...rows);
+ }
+ // Re-rank across chunks, then apply the cap once, so the export is the genuinely newest rows
+ // rather than the newest-per-chunk concatenated.
+ return collected
+ .sort((a, b) => b.created_at - a.created_at)
+ .slice(0, CONTACT_EXPORT_MAX_EVENTS);
}
/** The consent records authorizing a contact's linkage, for the export. The signed statement is
diff --git a/apps/server/src/db/crm.ts b/apps/server/src/db/crm.ts
index acd9a91..73a4bb8 100644
--- a/apps/server/src/db/crm.ts
+++ b/apps/server/src/db/crm.ts
@@ -160,11 +160,35 @@ function normalizeEmail(email: string | null | undefined): string | null {
* exactly what a single-level `err.message` check did.
*/
export function uniqueConstraintText(err: unknown): string | null {
+ return constraintText(err, /UNIQUE constraint failed/i);
+}
+
+/**
+ * True when the failure is a foreign-key violation — in this schema, always a contact pointing at a
+ * company that is no longer there.
+ *
+ * `resolveCompany` checks the company exists before the insert, but the check and the write are two
+ * statements: a `DELETE /companies/:id` committing between them makes the write fail on the
+ * constraint. Without this the error falls through to a 500, telling the caller the server is broken
+ * when in fact their request simply lost a race and `unknown_company` is the accurate answer.
+ */
+export function foreignKeyViolation(err: unknown): boolean {
+ return constraintText(err, /FOREIGN KEY constraint failed/i) !== null;
+}
+
+/**
+ * The text of a constraint violation matching `pattern`, or null for any other failure.
+ *
+ * Drizzle wraps driver errors (`DrizzleQueryError` carrying the D1 error as `cause`), and how deeply
+ * it nests them is a detail of the ORM version, not a contract. Walking the `cause` chain means a
+ * drizzle upgrade that adds or removes a wrapper changes nothing here.
+ */
+function constraintText(err: unknown, pattern: RegExp): string | null {
let current: unknown = err;
// Bounded, so a self-referential `cause` cannot spin here.
for (let depth = 0; depth < 5; depth++) {
if (!(current instanceof Error)) return null;
- if (/UNIQUE constraint failed/i.test(current.message)) return current.message;
+ if (pattern.test(current.message)) return current.message;
current = current.cause;
}
return null;
@@ -360,6 +384,45 @@ async function setCompanyFields(
if ('company_id' in input) set.company_id = null;
}
+/**
+ * Refuse a patch that would leave a contact with no email, no external id and no name.
+ *
+ * `ContactCreateSchema` enforces this at creation and states why: such a row "is not a contact, it is
+ * an empty row that can never be matched, deduped, or erased on request". A PATCH could reach exactly
+ * that state by blanking the three fields one request later, and the NULLs are distinct in both
+ * unique indexes so nothing downstream would object. The check has to run against the MERGED row —
+ * a patch that only clears `email` is fine when a name remains — so it reads the stored row rather
+ * than judging the patch alone, and only when the patch actually touches an identifier.
+ */
+async function assertStillIdentifiable(
+ binding: D1Database,
+ siteId: string,
+ id: string,
+ set: Record,
+): Promise {
+ const IDENTIFIERS = ['email', 'external_user_id', 'name'] as const;
+ if (!IDENTIFIERS.some((field) => field in set)) return;
+ const existing = await crmDb(binding)
+ .select({
+ email: crmSchema.contacts.email,
+ external_user_id: crmSchema.contacts.external_user_id,
+ name: crmSchema.contacts.name,
+ })
+ .from(crmSchema.contacts)
+ .where(and(eq(crmSchema.contacts.site_id, siteId), eq(crmSchema.contacts.id, id)))
+ .get();
+ // No row means the update will report 404 on its own; that is a better answer than this one.
+ if (!existing) return;
+ const survives = IDENTIFIERS.some((field) => (field in set ? set[field] : existing[field]));
+ if (!survives) {
+ throw new ApiError(
+ 'contact_needs_an_identifier',
+ 400,
+ 'a contact must keep at least one of email, external_user_id or name',
+ );
+ }
+}
+
/** Apply a partial update. Only keys actually present in `input` are written, so a PATCH that omits
* a field leaves it alone rather than nulling it. Returns the updated row in the resolved read
* shape, or undefined if the contact does not exist on this site. */
@@ -375,6 +438,7 @@ export async function updateContact(
if ('external_user_id' in input) set.external_user_id = orNull(input.external_user_id);
if ('email' in input) set.email = normalizeEmail(input.email);
if ('name' in input) set.name = orNull(input.name);
+ await assertStillIdentifiable(binding, siteId, id, set);
if ('phone' in input) set.phone = orNull(input.phone);
await setCompanyFields(client, siteId, input, set);
if ('title' in input) set.title = orNull(input.title);
@@ -563,17 +627,29 @@ export async function deleteCompany(
const client = crmDb(binding);
const company = await getCompany(binding, siteId, id);
if (!company) return undefined;
- const [unlinked, deleted] = await client.batch([
+ const atCompany = and(
+ eq(crmSchema.contacts.site_id, siteId),
+ eq(crmSchema.contacts.company_id, company.id),
+ );
+ const [counted, , deleted] = await client.batch([
+ // Counted inside the transaction rather than by materialising the rows: `.returning()` on the
+ // update would pull one row per contact across the wire to produce a single integer, which for
+ // a large account is tens of thousands of rows read to count them.
+ client
+ .select({ n: sql`count(*)` })
+ .from(crmSchema.contacts)
+ .where(atCompany),
client
.update(crmSchema.contacts)
- .set({ company: company.name, company_id: null })
- .where(
- and(
- eq(crmSchema.contacts.site_id, siteId),
- eq(crmSchema.contacts.company_id, company.id),
- ),
- )
- .returning({ id: crmSchema.contacts.id }),
+ // The name is read by a correlated subquery, INSIDE the transaction, not captured from the
+ // `getCompany` above. A rename committing between that read and this write would otherwise
+ // stamp every contact with the superseded name — and with the company row then deleted,
+ // nothing would remain to correct it against.
+ .set({
+ company: sql`(SELECT ${crmSchema.companies.name} FROM ${crmSchema.companies} WHERE ${crmSchema.companies.id} = ${company.id})`,
+ company_id: null,
+ })
+ .where(atCompany),
client
.delete(crmSchema.companies)
.where(and(eq(crmSchema.companies.site_id, siteId), eq(crmSchema.companies.id, id)))
@@ -581,5 +657,5 @@ export async function deleteCompany(
]);
// Lost a race with a concurrent delete: the batch changed nothing, and 404 is the honest answer.
if (deleted.length === 0) return undefined;
- return { company, contacts_unlinked: unlinked.length };
+ return { company, contacts_unlinked: counted[0]?.n ?? 0 };
}
diff --git a/apps/server/src/lib/consent.ts b/apps/server/src/lib/consent.ts
index f227e12..13f8779 100644
--- a/apps/server/src/lib/consent.ts
+++ b/apps/server/src/lib/consent.ts
@@ -27,6 +27,8 @@ import {
verifyStatement,
} from '@facet/trust';
import type { Env } from '../env.js';
+import { chunked } from './constants.js';
+import { deriveVisitorHash, readScopedSalt, saltScope } from './identity.js';
import { deploymentDid, getSigningKey } from './signing.js';
export const CONSENT_STATEMENT_TYPE = 'facet-consent/1';
@@ -181,6 +183,55 @@ export async function storeConsentRecord(env: Env, row: ConsentRecordRow): Promi
.run();
}
+/**
+ * Does this statement's hash actually belong to the uid the ROW is filed under?
+ *
+ * This is the check that makes the column safe to group by. The claims name a site, a tier and a
+ * hash, but they never name the uid — `external_user_id_present` is a bit, not a value — so a
+ * GENUINE, deployment-signed grant for Ada satisfies every other check when copied into a row whose
+ * `external_user_id` column says `bob-uid`. Signature verification cannot catch that: nothing is
+ * forged. Bob's contact page would simply show Ada's browsing.
+ *
+ * The binding is recoverable because the identified pre-image is `uid:|salt|siteId`, and the
+ * claims carry the window the salt belongs to. Recomputing the hash from the ROW's uid and requiring
+ * it to equal the SIGNED one closes the replay: a statement now authorizes exactly the person it was
+ * issued for.
+ *
+ * A missing salt fails closed. It cannot happen for a live grant — retention drops a consent record
+ * at `granted_at < cutoff` and its salt only at `window_end < cutoff`, and a window always ends after
+ * the grant inside it, so the salt outlives the record — but "the salt is gone" and "this hash is
+ * someone else's" are indistinguishable from here, and the safe reading of an unverifiable link is
+ * that there is no link.
+ */
+async function hashBelongsToUid(
+ env: Env,
+ siteId: string,
+ externalUserId: string,
+ claims: ConsentClaims,
+ saltCache: Map,
+): Promise {
+ const scope = saltScope(siteId, claims.salt_window, claims.window_key);
+ let salt = saltCache.get(scope);
+ if (salt === undefined) {
+ salt = await readScopedSalt(env, scope);
+ saltCache.set(scope, salt);
+ }
+ if (!salt) return false;
+ // An empty uid would fall through `buildPreimage`'s identified branch to the ip/ua pre-image and
+ // compare an unrelated hash, so it is rejected here rather than answered by accident.
+ if (!externalUserId) return false;
+ // `ip`/`ua` are structurally required by `DeriveInputs` and unused on the identified branch, whose
+ // pre-image is `uid:|salt|siteId`. Blanks state that rather than smuggling in values this
+ // check has no business knowing.
+ const expected = await deriveVisitorHash(
+ 'identified',
+ { ip: '', ua: '', uid: externalUserId },
+ salt,
+ siteId,
+ );
+ return expected === claims.visitor_hash;
+}
+
/**
* The ONE bridge from a CRM contact to analytics. Resolve a site's opaque `external_user_id` to the
* visitor hashes it is currently allowed to be linked to — one per salt window with a live grant.
@@ -221,11 +272,15 @@ export async function findLinkedVisitorHashes(
* from having no consent record at all.
*
* The `external_user_id` COLUMN groups the results and the SIGNED claims authorize them: the column
- * says which contact asked, the statement says what they may see. A row whose column names one uid
- * while pointing at another person's hash still has to survive the signature check, which is what
- * stops the grouping key from becoming an authorization key.
+ * says which contact asked, the statement says what they may see. Those two are tied together by
+ * `hashBelongsToUid` and not by the signature — a genuine statement carries no uid to check, so
+ * verification alone would happily let one person's grant be filed under another's id.
*
- * The caller must bound `externalUserIds`; this issues one query with one bind per id.
+ * The uid list is CHUNKED across statements rather than bound in one. D1 rejects any query carrying
+ * more than 100 bound parameters, and this one spends two of them on `site_id` and `now` — so a
+ * company of 99 linkable contacts asked for 101 and the statement was refused outright. That is a
+ * hard failure, not a slow one, and it lands on exactly the largest account rather than on the small
+ * ones a test would reach for.
*/
export async function findLinkedVisitorHashesForMany(
env: Env,
@@ -237,31 +292,38 @@ export async function findLinkedVisitorHashesForMany(
const loading = getSigningKey(env);
if (!loading) return byUid;
const key = await loading;
- const placeholders = lookup.externalUserIds.map(() => '?').join(', ');
- const { results } = await env.DB.prepare(
- `SELECT external_user_id, statement FROM consent_records WHERE site_id = ? AND external_user_id IN (${placeholders}) AND tier = 'identified' AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`,
- )
- .bind(lookup.siteId, ...lookup.externalUserIds, lookup.now)
- .all<{ external_user_id: string; statement: string }>();
const iss = deploymentDid(url);
const seen = new Map>();
- for (const row of results ?? []) {
- let stmt: SignedStatement;
- try {
- stmt = JSON.parse(row.statement) as SignedStatement;
- } catch {
- continue;
+ // Rows routinely share a salt window, so the salt is fetched once per window rather than per row.
+ const saltCache = new Map();
+ for (const batch of chunked(lookup.externalUserIds)) {
+ const placeholders = batch.map(() => '?').join(', ');
+ const { results } = await env.DB.prepare(
+ `SELECT external_user_id, statement FROM consent_records WHERE site_id = ? AND external_user_id IN (${placeholders}) AND tier = 'identified' AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`,
+ )
+ .bind(lookup.siteId, ...batch, lookup.now)
+ .all<{ external_user_id: string; statement: string }>();
+ for (const row of results ?? []) {
+ let stmt: SignedStatement;
+ try {
+ stmt = JSON.parse(row.statement) as SignedStatement;
+ } catch {
+ continue;
+ }
+ if (!(await verifyPinnedToDeployment(stmt, iss, key.kid))) continue;
+ const p = stmt.payload;
+ // The claims, not the columns: this grant must be for this site, at the identified tier,
+ // and must actually have been made against an external user id rather than an ip/ua
+ // pseudonym.
+ if (p.site_id !== lookup.siteId) continue;
+ if (p.tier !== 'identified') continue;
+ if (!p.external_user_id_present) continue;
+ if (!(await hashBelongsToUid(env, lookup.siteId, row.external_user_id, p, saltCache)))
+ continue;
+ const hashes = seen.get(row.external_user_id) ?? new Set();
+ hashes.add(p.visitor_hash);
+ seen.set(row.external_user_id, hashes);
}
- if (!(await verifyPinnedToDeployment(stmt, iss, key.kid))) continue;
- const p = stmt.payload;
- // The claims, not the columns: this grant must be for this site, at the identified tier, and
- // must actually have been made against an external user id rather than an ip/ua pseudonym.
- if (p.site_id !== lookup.siteId) continue;
- if (p.tier !== 'identified') continue;
- if (!p.external_user_id_present) continue;
- const hashes = seen.get(row.external_user_id) ?? new Set();
- hashes.add(p.visitor_hash);
- seen.set(row.external_user_id, hashes);
}
for (const [uid, hashes] of seen) {
byUid.set(uid, [...hashes]);
diff --git a/apps/server/src/lib/constants.ts b/apps/server/src/lib/constants.ts
index 94d30fa..cb27827 100644
--- a/apps/server/src/lib/constants.ts
+++ b/apps/server/src/lib/constants.ts
@@ -47,3 +47,32 @@ export const EXPORT_MAX_ROWS = 1000 as const;
/** Trailing window for the realtime "active visitors" metric, in milliseconds (5 minutes). */
export const REALTIME_WINDOW_MS = 300_000 as const;
+
+/**
+ * Largest body a CRM write may carry. The global `bodyLimit` is path-scoped to `/api/collect`, so
+ * without this the one route group that stores personal data was the one with no ceiling at all.
+ * Generous against the field bounds — a contact's `notes` alone may be 4000 characters — because this
+ * is a backstop against an unbounded upload, not a second copy of the wire schema.
+ */
+export const CRM_MAX_BODY_BYTES = 16_384 as const;
+
+/**
+ * How many values one `IN (...)` list may carry.
+ *
+ * D1 rejects any statement with more than 100 bound parameters ("too many SQL variables"), and every
+ * such query spends some of that budget on its other predicates — a site id, a timestamp, a limit. So
+ * the list gets a margin rather than the whole allowance, and anything longer is chunked across
+ * statements. This is not a tuning knob: exceed it and the query does not run slowly, it fails.
+ */
+export const D1_MAX_IN_PARAMS = 90 as const;
+
+/** Split `values` into runs of at most `size`, for queries whose `IN (...)` list would otherwise
+ * exceed D1's bound-parameter limit. An empty input yields no chunks, so a caller can iterate the
+ * result without a special case for "nothing to look up". */
+export function chunked(values: readonly T[], size: number = D1_MAX_IN_PARAMS): T[][] {
+ const out: T[][] = [];
+ for (let i = 0; i < values.length; i += size) {
+ out.push(values.slice(i, i + size));
+ }
+ return out;
+}
diff --git a/apps/server/src/lib/identity.ts b/apps/server/src/lib/identity.ts
index b114b33..e2c8be7 100644
--- a/apps/server/src/lib/identity.ts
+++ b/apps/server/src/lib/identity.ts
@@ -144,6 +144,26 @@ export async function resolvePolicy(env: Env, siteId: string): Promise {
+ const row = await env.DB.prepare('SELECT salt FROM identity_salts WHERE scope = ?')
+ .bind(scope)
+ .first<{ salt: string }>();
+ return row?.salt ?? null;
+}
+
export async function getScopedSalt(
env: Env,
scope: string,
diff --git a/apps/server/src/routes/consent.ts b/apps/server/src/routes/consent.ts
index 6d45252..d47dcae 100644
--- a/apps/server/src/routes/consent.ts
+++ b/apps/server/src/routes/consent.ts
@@ -23,6 +23,7 @@ import {
deriveVisitorHash,
getScopedSalt,
resolvePolicy,
+ saltScope,
windowEndMs,
windowKey,
} from '../lib/identity.js';
@@ -57,7 +58,7 @@ consentRoutes.post(
}
const now = Date.now();
const wk = windowKey(policy.window, now);
- const scope = `${siteId}:${policy.window}:${wk}`;
+ const scope = saltScope(siteId, policy.window, wk);
const salt = await getScopedSalt(
c.env,
scope,
@@ -126,7 +127,7 @@ consentRoutes.delete(
return c.json({ revoked: 0 });
}
const wk = windowKey(policy.window, now);
- const scope = `${siteId}:${policy.window}:${wk}`;
+ const scope = saltScope(siteId, policy.window, wk);
const salt = await getScopedSalt(
c.env,
scope,
diff --git a/apps/server/src/routes/crm.ts b/apps/server/src/routes/crm.ts
index 8a881fb..3010464 100644
--- a/apps/server/src/routes/crm.ts
+++ b/apps/server/src/routes/crm.ts
@@ -41,6 +41,7 @@ import {
import { vValidator } from '@hono/valibot-validator';
import { eq } from 'drizzle-orm';
import { Hono } from 'hono';
+import { bodyLimit } from 'hono/body-limit';
import {
COMPANY_ROLLUP_MAX_CONTACTS,
CONTACT_EXPORT_MAX_EVENTS,
@@ -54,6 +55,7 @@ import {
companyContactLinkage,
deleteCompany,
deleteContact,
+ foreignKeyViolation,
getCompany,
getContact,
insertCompany,
@@ -76,12 +78,41 @@ import {
findLinkedVisitorHashes,
findLinkedVisitorHashesForMany,
} from '../lib/consent.js';
+import { CRM_MAX_BODY_BYTES } from '../lib/constants.js';
import { ApiError, validationErrorHook } from '../lib/http.js';
+import { rateLimit } from '../lib/ratelimit.js';
export const crmRoutes = new Hono();
crmRoutes.use('*', requireCrm);
+// The global body limit is path-scoped to /api/collect, so it never reached here — leaving the one
+// route group that stores personal data as the only one accepting an unbounded upload.
+crmRoutes.use(
+ '*',
+ bodyLimit({
+ maxSize: CRM_MAX_BODY_BYTES,
+ onError: () => {
+ throw new ApiError('payload_too_large', 413);
+ },
+ }),
+);
+
+/**
+ * Rate limit, keyed by the OPERATOR rather than the site.
+ *
+ * Everything else in this codebase keys its bucket per site, because the risk it manages is one
+ * tenant's traffic drowning another's. The risk here is different: these are the only routes that
+ * return names, emails and phone numbers, and the threat is a single stolen session pulling the whole
+ * table a page at a time. Keying per site would let a compromised operator hide inside their team's
+ * legitimate traffic and would punish their colleagues for it; keying per operator caps the session
+ * that is actually doing it.
+ *
+ * Applied AFTER the role guard at every call site, matching /api/event: an unauthenticated request is
+ * rejected before it can consume anyone's bucket, and `userId` is only set once a session resolves.
+ */
+const crmRateLimit = rateLimit((c) => `crm:${c.get('userId') ?? 'unauthenticated'}`);
+
/** Resolve a contact or raise the canonical 404. Scoped by the authorized site, so a contact id from
* another site is indistinguishable from one that does not exist. */
async function loadContact(env: Env, siteId: string, id: string): Promise {
@@ -108,6 +139,36 @@ async function assertOwnerExists(env: Env, ownerUserId: string | undefined): Pro
}
}
+/**
+ * Map a failed contact write onto the status it deserves, or rethrow.
+ *
+ * The unique indexes on `(site_id, email)` and `(site_id, external_user_id)` are the dedupe, and
+ * naming the field that collided is safe: the caller holds a role on this site and submitted the
+ * value themselves. A foreign-key failure means the company was deleted between this request
+ * resolving it and writing the row — the caller lost a race, which is a 400 about their `company_id`
+ * and not a 500 about the server.
+ */
+function contactWriteError(err: unknown): never {
+ const conflict = uniqueConstraintText(err);
+ if (conflict) {
+ throw new ApiError(
+ 'contact_exists',
+ 409,
+ /external_user_id/i.test(conflict)
+ ? 'a contact with this external_user_id already exists'
+ : 'a contact with this email already exists',
+ );
+ }
+ if (foreignKeyViolation(err)) {
+ throw new ApiError(
+ 'unknown_company',
+ 400,
+ 'company_id does not match a company on this site',
+ );
+ }
+ throw err;
+}
+
/** A contact's currently-authorized visitor hashes, or [] when nothing authorizes a link. */
function linkedHashes(
env: Env,
@@ -123,9 +184,19 @@ function linkedHashes(
});
}
+/**
+ * Every list response carries the role it was served under.
+ *
+ * A client has no other way to learn it. `GET /api/auth/me` reports a role per TEAM, and no
+ * session-reachable route maps a site to its owning team — that lives behind the admin token — so a
+ * browser deciding whether to offer the admin-only Delete and Export could only guess. Guessing high
+ * offers a button that answers 403; guessing low hides one the operator is entitled to. The server
+ * already resolved the exact role to authorize this very request, so it says so.
+ */
crmRoutes.get(
'/contacts',
requireTeamRole('analyst'),
+ crmRateLimit,
vValidator('query', ContactListQuerySchema, validationErrorHook),
async (c) => {
const query = c.req.valid('query');
@@ -135,13 +206,14 @@ crmRoutes.get(
limit: query.limit ?? CRM_DEFAULT_PAGE,
offset: query.offset ?? 0,
});
- return c.json({ contacts, total });
+ return c.json({ contacts, total, role: c.get('role') });
},
);
crmRoutes.post(
'/contacts',
requireTeamRole('analyst'),
+ crmRateLimit,
vValidator('json', ContactCreateSchema, validationErrorHook),
async (c) => {
const body = c.req.valid('json');
@@ -152,27 +224,12 @@ crmRoutes.post(
c.get('siteId'),
body,
Date.now(),
- ).catch((err: unknown) => {
- // The (site_id, email) / (site_id, external_user_id) unique indexes are the dedupe. A
- // collision is a client mistake, not a server fault, and saying which field collided is
- // safe: the caller already holds a role on this site and submitted the value itself.
- const conflict = uniqueConstraintText(err);
- if (conflict) {
- throw new ApiError(
- 'contact_exists',
- 409,
- /external_user_id/i.test(conflict)
- ? 'a contact with this external_user_id already exists'
- : 'a contact with this email already exists',
- );
- }
- throw err;
- });
+ ).catch(contactWriteError);
return c.json({ contact }, 201);
},
);
-crmRoutes.get('/contacts/:id', requireTeamRole('analyst'), async (c) => {
+crmRoutes.get('/contacts/:id', requireTeamRole('analyst'), crmRateLimit, async (c) => {
const contact = await loadContact(c.env, c.get('siteId'), c.req.param('id'));
return c.json({ contact });
});
@@ -180,6 +237,7 @@ crmRoutes.get('/contacts/:id', requireTeamRole('analyst'), async (c) => {
crmRoutes.patch(
'/contacts/:id',
requireTeamRole('analyst'),
+ crmRateLimit,
vValidator('json', ContactUpdateSchema, validationErrorHook),
async (c) => {
const body = c.req.valid('json');
@@ -190,16 +248,7 @@ crmRoutes.patch(
c.req.param('id') ?? '',
body,
Date.now(),
- ).catch((err: unknown) => {
- if (uniqueConstraintText(err)) {
- throw new ApiError(
- 'contact_exists',
- 409,
- 'another contact already holds that value',
- );
- }
- throw err;
- });
+ ).catch(contactWriteError);
if (!contact) {
throw new ApiError('not_found', 404);
}
@@ -218,25 +267,33 @@ crmRoutes.patch(
* keyed by a salted hash, and with the consent record gone nothing can ever re-associate them with a
* person; destroying the link is what erasure of the identifiable data means here.
*/
-crmRoutes.delete('/contacts/:id', requireTeamRole('admin'), async (c) => {
+crmRoutes.delete('/contacts/:id', requireTeamRole('admin'), crmRateLimit, async (c) => {
const siteId = c.get('siteId');
- const contact = await deleteContact(requireCrmDb(c.env), siteId, c.req.param('id'));
- if (!contact) {
- throw new ApiError('not_found', 404);
- }
+ const contact = await loadContact(c.env, siteId, c.req.param('id') ?? '');
+ // The two writes land in DIFFERENT databases and D1 has no transaction spanning them, so one of
+ // them can be left undone. The order decides which. Erasing the consent records FIRST means a
+ // failure leaves the contact row still present and still naming the uid — an erasure that can
+ // simply be retried. Deleting the contact first would mean a failure destroys the only record of
+ // which uid to erase, stranding rows that hold that person's raw identifier: exactly the data the
+ // request was about, now unreachable by any retry.
const consentErased = contact.external_user_id
? await eraseConsentByExternalUserId(c.env, {
siteId,
externalUserId: contact.external_user_id,
})
: 0;
+ const deleted = await deleteContact(requireCrmDb(c.env), siteId, contact.id);
+ if (!deleted) {
+ // Lost a race with a concurrent delete, which already erased the same consent rows.
+ throw new ApiError('not_found', 404);
+ }
return c.json({ deleted: true, consent_records_erased: consentErased });
});
/** A contact's analytics, if and only if an active signed consent record authorizes the link. When
* it does not, the response says so explicitly rather than returning zeroes that read like "this
* person did nothing" — `linked: false` and a reason are the honest answer. */
-crmRoutes.get('/contacts/:id/analytics', requireTeamRole('analyst'), async (c) => {
+crmRoutes.get('/contacts/:id/analytics', requireTeamRole('analyst'), crmRateLimit, async (c) => {
const siteId = c.get('siteId');
const contact = await loadContact(c.env, siteId, c.req.param('id'));
if (!contact.external_user_id) {
@@ -263,7 +320,7 @@ crmRoutes.get('/contacts/:id/analytics', requireTeamRole('analyst'), async (c) =
* (their claims are a derived hash, a tier and a window), so including them adds cryptographic
* evidence of what was consented to without widening what the export reveals.
*/
-crmRoutes.get('/contacts/:id/export', requireTeamRole('admin'), async (c) => {
+crmRoutes.get('/contacts/:id/export', requireTeamRole('admin'), crmRateLimit, async (c) => {
const siteId = c.get('siteId');
const contact = await loadContact(c.env, siteId, c.req.param('id'));
const externalUserId = contact.external_user_id;
@@ -321,6 +378,7 @@ function companyConflict(err: unknown): never {
crmRoutes.get(
'/companies',
requireTeamRole('analyst'),
+ crmRateLimit,
vValidator('query', CompanyListQuerySchema, validationErrorHook),
async (c) => {
const query = c.req.valid('query');
@@ -330,13 +388,14 @@ crmRoutes.get(
limit: query.limit ?? CRM_DEFAULT_PAGE,
offset: query.offset ?? 0,
});
- return c.json({ companies, total });
+ return c.json({ companies, total, role: c.get('role') });
},
);
crmRoutes.post(
'/companies',
requireTeamRole('analyst'),
+ crmRateLimit,
vValidator('json', CompanyCreateSchema, validationErrorHook),
async (c) => {
const body = c.req.valid('json');
@@ -352,7 +411,7 @@ crmRoutes.post(
},
);
-crmRoutes.get('/companies/:id', requireTeamRole('analyst'), async (c) => {
+crmRoutes.get('/companies/:id', requireTeamRole('analyst'), crmRateLimit, async (c) => {
const company = await loadCompany(c.env, c.get('siteId'), c.req.param('id'));
return c.json({ company });
});
@@ -360,6 +419,7 @@ crmRoutes.get('/companies/:id', requireTeamRole('analyst'), async (c) => {
crmRoutes.patch(
'/companies/:id',
requireTeamRole('analyst'),
+ crmRateLimit,
vValidator('json', CompanyUpdateSchema, validationErrorHook),
async (c) => {
const body = c.req.valid('json');
@@ -387,7 +447,7 @@ crmRoutes.patch(
* `admin` rather than `analyst` because it is irreversible and it rewrites rows the caller did not
* name — the same reason deleting a contact is.
*/
-crmRoutes.delete('/companies/:id', requireTeamRole('admin'), async (c) => {
+crmRoutes.delete('/companies/:id', requireTeamRole('admin'), crmRateLimit, async (c) => {
const result = await deleteCompany(requireCrmDb(c.env), c.get('siteId'), c.req.param('id'));
if (!result) {
throw new ApiError('not_found', 404);
@@ -398,6 +458,7 @@ crmRoutes.delete('/companies/:id', requireTeamRole('admin'), async (c) => {
crmRoutes.get(
'/companies/:id/contacts',
requireTeamRole('analyst'),
+ crmRateLimit,
vValidator('query', CompanyContactsQuerySchema, validationErrorHook),
async (c) => {
const siteId = c.get('siteId');
@@ -409,7 +470,7 @@ crmRoutes.get(
company.id,
{ limit: query.limit ?? CRM_DEFAULT_PAGE, offset: query.offset ?? 0 },
);
- return c.json({ contacts, total });
+ return c.json({ contacts, total, role: c.get('role') });
},
);
@@ -434,7 +495,7 @@ crmRoutes.get(
* start reasoning about the eleven who never consented. So `contacts_total` and `contacts_linked` are
* reported side by side and one-of-twelve is visible as one-of-twelve.
*/
-crmRoutes.get('/companies/:id/analytics', requireTeamRole('analyst'), async (c) => {
+crmRoutes.get('/companies/:id/analytics', requireTeamRole('analyst'), crmRateLimit, async (c) => {
const siteId = c.get('siteId');
const company = await loadCompany(c.env, siteId, c.req.param('id'));
const linkage = await companyContactLinkage(
@@ -461,7 +522,16 @@ crmRoutes.get('/companies/:id/analytics', requireTeamRole('analyst'), async (c)
if (hashes.length === 0) {
// Same honesty as the contact route: zeroes would read as "this account did nothing", which is
// a different claim from "nobody here has authorized a link".
- return c.json({ ...counts, linked: false, reason: 'no_linked_contacts' });
+ //
+ // And when the fan-out was capped, even THAT is more than can be claimed. The contacts
+ // resolved are the newest `contacts_limit`; older ones outside the window may well be linked,
+ // so the honest reason names what was actually examined rather than asserting a fact about
+ // contacts nobody looked at.
+ return c.json({
+ ...counts,
+ linked: false,
+ reason: linkage.truncated ? 'none_linked_within_cap' : 'no_linked_contacts',
+ });
}
return c.json({
...counts,
diff --git a/apps/server/test/contact-analytics-chunking.test.ts b/apps/server/test/contact-analytics-chunking.test.ts
new file mode 100644
index 0000000..9d87980
--- /dev/null
+++ b/apps/server/test/contact-analytics-chunking.test.ts
@@ -0,0 +1,92 @@
+// The chunked fan-out in db/contact-analytics.ts. D1 refuses a query with more than 100 bound
+// parameters, so a hash list longer than the chunk size is split across statements and merged here
+// rather than in SQL. Merging is where an aggregate quietly stops being exact, so these tests pin the
+// two ways it could: totals that fail to add up, and a ranking assembled from per-chunk prefixes.
+
+import { env } from 'cloudflare:test';
+import { beforeEach, describe, expect, it } from 'vitest';
+import { contactActivity, contactEvents } from '../src/db/contact-analytics.js';
+import { D1_MAX_IN_PARAMS } from '../src/lib/constants.js';
+
+const SITE = '66666666-6666-4666-8666-666666666666';
+
+/** Distinct 64-hex hashes, so each lands in exactly one chunk. */
+function hash(i: number): string {
+ return i.toString(16).padStart(64, '0');
+}
+
+async function seed(rows: { hash: string; path: string; at: number }[]): Promise {
+ const insert = env.DB.prepare(
+ `INSERT INTO events (id, site_id, name, hostname, path, referrer, visitor_hash, created_at)
+ VALUES (?, ?, NULL, 'shop.example.com', ?, '', ?, ?)`,
+ );
+ for (let i = 0; i < rows.length; i += 400) {
+ await env.DB.batch(
+ rows
+ .slice(i, i + 400)
+ .map((r) => insert.bind(crypto.randomUUID(), SITE, r.path, r.hash, r.at)),
+ );
+ }
+}
+
+beforeEach(async () => {
+ await env.DB.prepare(
+ 'INSERT OR IGNORE INTO sites (id, name, domain, created_at) VALUES (?, ?, ?, ?)',
+ )
+ .bind(SITE, 'Test', 'shop.example.com', Date.now())
+ .run();
+});
+
+describe('a hash list longer than one query can bind', () => {
+ it('adds the totals up across chunks instead of reporting one of them', async () => {
+ const n = D1_MAX_IN_PARAMS + 5;
+ const hashes = Array.from({ length: n }, (_, i) => hash(i + 1));
+ // One pageview per hash, each at a distinct time so the extremes are unambiguous.
+ await seed(hashes.map((h, i) => ({ hash: h, path: '/pricing', at: 1_000 + i })));
+
+ const activity = await contactActivity(env, SITE, hashes);
+ expect(activity.total).toBe(n);
+ expect(activity.pageviews).toBe(n);
+ // first/last must span every chunk, not just the last one processed.
+ expect(activity.first_seen).toBe(1_000);
+ expect(activity.last_seen).toBe(1_000 + n - 1);
+ });
+
+ it('ranks paths over the whole set, not over each chunk separately', async () => {
+ // The discriminating case for taking a per-chunk prefix. `/sleeper` is rank 13 within BOTH
+ // chunks, so any implementation that applied `LIMIT 10` per chunk and merged the survivors
+ // would drop it entirely — yet it is the single most-viewed path overall.
+ const chunkA = Array.from({ length: D1_MAX_IN_PARAMS }, (_, i) => hash(i + 1));
+ const chunkB = Array.from({ length: 5 }, (_, i) => hash(D1_MAX_IN_PARAMS + i + 1));
+ const rows: { hash: string; path: string; at: number }[] = [];
+ let t = 0;
+ const push = (h: string, path: string, times: number) => {
+ for (let i = 0; i < times; i++) rows.push({ hash: h, path, at: 5_000 + t++ });
+ };
+ for (let p = 0; p < 12; p++) push(chunkA[p] as string, `/a${p}`, 12);
+ for (let p = 0; p < 12; p++) push(chunkB[p % chunkB.length] as string, `/b${p}`, 12);
+ // Ten views in each chunk: below every per-chunk leader, above all of them combined.
+ push(chunkA[50] as string, '/sleeper', 10);
+ push(chunkB[4] as string, '/sleeper', 10);
+ await seed(rows);
+
+ const activity = await contactActivity(env, SITE, [...chunkA, ...chunkB]);
+ expect(activity.top_paths[0]).toEqual({ path: '/sleeper', views: 20 });
+ expect(activity.total).toBe(12 * 12 + 12 * 12 + 20);
+ });
+
+ it('returns the genuinely newest events, not the newest within each chunk', async () => {
+ // Same trap for the export: a per-chunk cap concatenated would return the later chunk's older
+ // rows ahead of the earlier chunk's newer ones.
+ const chunkA = Array.from({ length: D1_MAX_IN_PARAMS }, (_, i) => hash(i + 1));
+ const chunkB = [hash(D1_MAX_IN_PARAMS + 1)];
+ await seed([
+ ...chunkA.map((h, i) => ({ hash: h, path: '/old', at: 1_000 + i })),
+ { hash: chunkB[0] as string, path: '/newest', at: 9_999_999 },
+ ]);
+
+ const events = await contactEvents(env, SITE, [...chunkA, ...chunkB]);
+ expect(events[0]?.path).toBe('/newest');
+ expect(events.length).toBe(chunkA.length + 1);
+ });
+});
diff --git a/apps/server/test/crm.test.ts b/apps/server/test/crm.test.ts
index 801a353..e2ebfbb 100644
--- a/apps/server/test/crm.test.ts
+++ b/apps/server/test/crm.test.ts
@@ -8,11 +8,15 @@
// events sitting right there and is still excluded — revoked consent, and a forged statement.
import { env } from 'cloudflare:test';
+import { CRM_MAX_OFFSET } from '@facet/shared';
import { generateSigningJwk } from '@facet/trust';
import { beforeEach, describe, expect, it } from 'vitest';
import { createApp } from '../src/app.js';
-import { CONTACT_EXPORT_MAX_EVENTS } from '../src/db/contact-analytics.js';
-import { companyContactLinkage } from '../src/db/crm.js';
+import {
+ COMPANY_ROLLUP_MAX_CONTACTS,
+ CONTACT_EXPORT_MAX_EVENTS,
+} from '../src/db/contact-analytics.js';
+import { companyContactLinkage, foreignKeyViolation } from '../src/db/crm.js';
import {
SESSION_COOKIE,
signSession,
@@ -1157,3 +1161,284 @@ describe('the company rollup sums consent, it does not bypass it', () => {
expect(uncapped.truncated).toBe(false);
});
});
+
+describe('the rollup fan-out stays inside D1 limits', () => {
+ it('answers for a company larger than one query can bind', async () => {
+ // D1 allows 100 bound parameters per query. The consent lookup binds site_id and `now` on top
+ // of one per contact, so a company with 99 linkable contacts asks for 101 and the statement is
+ // rejected outright — a hard 500 on exactly the large account that most wants a rollup, while
+ // every small company a test would naturally use keeps working.
+ // A signing key is required, or the consent lookup returns before it ever builds the query and
+ // the fan-out under test never happens.
+ const e = await withSigningKey(env);
+ const cookie = await operator(e, 'admin@example.com', 'admin');
+ const company = await createCompany(e, cookie, { name: 'Acme' });
+ const now = Date.now();
+ const insert = e.CRM_DB.prepare(
+ `INSERT INTO contacts (id, site_id, external_user_id, name, company_id, status, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, 'lead', ?, ?)`,
+ );
+ const n = 99;
+ await e.CRM_DB.batch(
+ Array.from({ length: n }, (_, i) =>
+ insert.bind(
+ crypto.randomUUID(),
+ SITE,
+ `uid-${i}`,
+ `Person ${i}`,
+ company.id,
+ now - i,
+ now,
+ ),
+ ),
+ );
+ const res = await crm(e, `/companies/${company.id}/analytics`, {}, cookie);
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { contacts_total: number; contacts_linked: number };
+ expect(body.contacts_total).toBe(n);
+ // Nobody consented, so the honest answer is zero linked — but it has to be an ANSWER.
+ expect(body.contacts_linked).toBe(0);
+ });
+});
+
+describe('a patch cannot strip a contact of every identifier', () => {
+ it('refuses to blank email, external id and name all at once', async () => {
+ // `ContactCreateSchema` rejects a row with none of the three because such a row "can never be
+ // matched, deduped, or erased on request". A PATCH one request later could reach exactly that
+ // state, and NULLs are distinct in both unique indexes so nothing downstream would object.
+ const cookie = await operator(env, 'admin@example.com', 'admin');
+ const contact = await createContact(env, cookie, { name: 'Ada', email: 'ada@example.com' });
+ const res = await crm(
+ env,
+ `/contacts/${contact.id}`,
+ {
+ method: 'PATCH',
+ body: JSON.stringify({ name: '', email: '', external_user_id: '' }),
+ },
+ cookie,
+ );
+ expect(res.status).toBe(400);
+ expect(await res.json()).toMatchObject({ error: 'contact_needs_an_identifier' });
+ // And the row is untouched, not half-blanked.
+ const after = await crm(env, `/contacts/${contact.id}`, {}, cookie);
+ expect((await after.json()) as { contact: { name: string } }).toMatchObject({
+ contact: { name: 'Ada', email: 'ada@example.com' },
+ });
+ });
+
+ it('still lets one identifier be cleared while another survives', async () => {
+ // The check is against the MERGED row, not the patch: clearing the email of a contact who
+ // still has a name is ordinary editing and must not be blocked.
+ const cookie = await operator(env, 'admin@example.com', 'admin');
+ const contact = await createContact(env, cookie, { name: 'Ada', email: 'ada@example.com' });
+ const res = await crm(
+ env,
+ `/contacts/${contact.id}`,
+ { method: 'PATCH', body: JSON.stringify({ email: '' }) },
+ cookie,
+ );
+ expect(res.status).toBe(200);
+ expect((await res.json()) as { contact: { email: null } }).toMatchObject({
+ contact: { email: null, name: 'Ada' },
+ });
+ });
+});
+
+describe('the foreign key is a real constraint, not a comment', () => {
+ it('refuses a contact pointing at a company that does not exist', async () => {
+ // `resolveCompany` is the site-scoped check and this is the backstop underneath it. If D1 did
+ // not enforce the constraint, the schema's claim that a bad link "cannot" be written would be
+ // decoration, and the race between resolving a company and inserting the row would corrupt
+ // data silently instead of failing loudly.
+ let message = '';
+ try {
+ await env.CRM_DB.prepare(
+ `INSERT INTO contacts (id, site_id, name, company_id, status, created_at, updated_at)
+ VALUES (?, ?, 'Ada', 'does-not-exist', 'lead', 1, 1)`,
+ )
+ .bind(crypto.randomUUID(), SITE)
+ .run();
+ } catch (err) {
+ message = err instanceof Error ? err.message : String(err);
+ }
+ expect(message).toMatch(/FOREIGN KEY constraint failed/i);
+ // And the classifier the route relies on recognises the real error shape, not a guessed one.
+ expect(foreignKeyViolation(new Error(message))).toBe(true);
+ expect(foreignKeyViolation(new Error('UNIQUE constraint failed: contacts.email'))).toBe(
+ false,
+ );
+ });
+});
+
+describe('a capped rollup does not claim what it did not look at', () => {
+ it('names the cap rather than asserting nobody is linked', async () => {
+ // With the fan-out truncated, "no linked contacts" is a statement about contacts that were
+ // never examined. The older ones outside the window may well be linked.
+ const e = await withSigningKey(env);
+ const cookie = await operator(e, 'admin@example.com', 'admin');
+ const company = await createCompany(e, cookie, { name: 'Acme' });
+ const now = Date.now();
+ const insert = e.CRM_DB.prepare(
+ `INSERT INTO contacts (id, site_id, external_user_id, name, company_id, status, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, 'lead', ?, ?)`,
+ );
+ const n = COMPANY_ROLLUP_MAX_CONTACTS + 1;
+ for (let i = 0; i < n; i += 200) {
+ await e.CRM_DB.batch(
+ Array.from({ length: Math.min(200, n - i) }, (_, j) =>
+ insert.bind(
+ crypto.randomUUID(),
+ SITE,
+ `uid-${i + j}`,
+ `Person ${i + j}`,
+ company.id,
+ now - (i + j),
+ now,
+ ),
+ ),
+ );
+ }
+ const res = await crm(e, `/companies/${company.id}/analytics`, {}, cookie);
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({
+ linked: false,
+ reason: 'none_linked_within_cap',
+ contacts_total: n,
+ contacts_considered: COMPANY_ROLLUP_MAX_CONTACTS,
+ contacts_truncated: true,
+ });
+ });
+});
+
+describe('a genuine consent statement authorizes only the person it was issued for', () => {
+ it('refuses a real, deployment-signed grant filed under another contact id', async () => {
+ // The gap signature verification cannot see. The claims name a site, a tier and a hash, but
+ // never the uid — `external_user_id_present` is a bit, not a value — so Ada's UNMODIFIED,
+ // validly-signed statement satisfies every signature and claim check when copied into a row
+ // whose `external_user_id` column says someone else. Nothing is forged, so the crypto has no
+ // objection; only recomputing the hash from the row's uid can tell the two apart.
+ //
+ // The statement is obtained the way an operator really could: the contact export returns it
+ // verbatim, deliberately, as cryptographic evidence of what was consented to.
+ const e = await withSigningKey(env);
+ const cookie = await operator(e, 'admin@example.com', 'admin');
+ await e.DB.prepare(
+ 'INSERT OR REPLACE INTO site_config (site_id, tier, salt_window, updated_at) VALUES (?, ?, ?, ?)',
+ )
+ .bind(SITE, 'identified', 'day', Date.now())
+ .run();
+ const { key } = await issueKey(e, SITE, 'server', Date.now());
+ const grant = await app.request(
+ '/api/consent',
+ {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' },
+ body: JSON.stringify({
+ tier: 'identified',
+ salt_window: 'day',
+ user_id: 'ada-uid',
+ ip: '203.0.113.9',
+ user_agent: 'test-agent',
+ }),
+ },
+ e,
+ );
+ expect(grant.status).toBe(201);
+ const ada = await e.DB.prepare(
+ 'SELECT visitor_hash, statement FROM consent_records WHERE site_id = ? AND external_user_id = ?',
+ )
+ .bind(SITE, 'ada-uid')
+ .first<{ visitor_hash: string; statement: string }>();
+
+ // Ada really browsed; these are her events, reachable if the gate fails.
+ const insert = e.DB.prepare(
+ `INSERT INTO events (id, site_id, name, hostname, path, referrer, visitor_hash, created_at)
+ VALUES (?, ?, NULL, 'shop.example.com', '/pricing', '', ?, ?)`,
+ );
+ await e.DB.batch([
+ insert.bind(crypto.randomUUID(), SITE, ada?.visitor_hash as string, Date.now() - 10),
+ insert.bind(crypto.randomUUID(), SITE, ada?.visitor_hash as string, Date.now() - 5),
+ ]);
+
+ // Ada's statement, byte for byte, filed under Mallory's id.
+ await e.DB.prepare(
+ `INSERT INTO consent_records
+ (id, site_id, visitor_hash, tier, external_user_id, salt_window, window_key, gpc_at_grant, granted_at, expires_at, revoked_at, statement)
+ SELECT ?, site_id, visitor_hash, tier, ?, salt_window, window_key, gpc_at_grant, granted_at, expires_at, revoked_at, statement
+ FROM consent_records WHERE site_id = ? AND external_user_id = ?`,
+ )
+ .bind(crypto.randomUUID(), 'mallory-uid', SITE, 'ada-uid')
+ .run();
+
+ const mallory = await createContact(e, cookie, {
+ name: 'Mallory',
+ external_user_id: 'mallory-uid',
+ });
+ const res = await crm(e, `/contacts/${mallory.id}/analytics`, {}, cookie);
+ expect(await res.json()).toMatchObject({ linked: false, reason: 'no_active_consent' });
+
+ // And the rightful owner is unaffected — the check binds the grant, it does not break it.
+ const adaContact = await createContact(e, cookie, {
+ name: 'Ada',
+ external_user_id: 'ada-uid',
+ });
+ const hers = await crm(e, `/contacts/${adaContact.id}/analytics`, {}, cookie);
+ const body = (await hers.json()) as { linked: boolean; activity: { total: number } };
+ expect(body.linked).toBe(true);
+ expect(body.activity.total).toBe(2);
+ });
+});
+
+describe('the PII routes are bounded, not just authenticated', () => {
+ /** An env whose rate limiter denies everything, which is how a wired-up limiter is distinguished
+ * from one that was never attached. The real binding is absent in tests, so the middleware
+ * no-ops and its presence is otherwise unobservable. */
+ function denyingLimiter(e: TestEnv): TestEnv {
+ return { ...e, RATE_LIMITER: { limit: async () => ({ success: false }) } } as TestEnv;
+ }
+
+ it('rate limits an authenticated operator, and only after authenticating them', async () => {
+ const e = denyingLimiter(env);
+ const cookie = await operator(e, 'analyst@example.com', 'analyst');
+ const limited = await crm(e, '/contacts', {}, cookie);
+ expect(limited.status).toBe(429);
+ expect(limited.headers.get('Retry-After')).toBe('60');
+
+ // Auth still runs first: an anonymous caller is rejected as unauthorized rather than being
+ // told it was rate limited, so an unauthenticated flood cannot consume anyone's bucket.
+ const anonymous = await crm(e, '/contacts');
+ expect(anonymous.status).toBe(401);
+
+ // A viewer is refused on role, also before the limiter.
+ const viewerCookie = await operator(e, 'viewer@example.com', 'viewer');
+ expect((await crm(e, '/contacts', {}, viewerCookie)).status).toBe(403);
+ });
+
+ it('covers the company routes too, not just contacts', async () => {
+ const e = denyingLimiter(env);
+ const cookie = await operator(e, 'admin@example.com', 'admin');
+ expect((await crm(e, '/companies', {}, cookie)).status).toBe(429);
+ });
+
+ it('refuses an oversized write body', async () => {
+ // The global bodyLimit is scoped to /api/collect, so before this the one route group storing
+ // personal data was the only one accepting an unbounded upload.
+ const cookie = await operator(env, 'admin@example.com', 'admin');
+ const res = await crm(
+ env,
+ '/contacts',
+ { method: 'POST', body: JSON.stringify({ name: 'Ada', notes: 'x'.repeat(50_000) }) },
+ cookie,
+ );
+ expect(res.status).toBe(413);
+ });
+
+ it('refuses to page arbitrarily deep', async () => {
+ const cookie = await operator(env, 'admin@example.com', 'admin');
+ expect((await crm(env, `/contacts?offset=${CRM_MAX_OFFSET + 1}`, {}, cookie)).status).toBe(
+ 400,
+ );
+ // The ceiling itself is still reachable, so this is a bound and not an off-by-one.
+ expect((await crm(env, `/contacts?offset=${CRM_MAX_OFFSET}`, {}, cookie)).status).toBe(200);
+ });
+});
diff --git a/docs/api.md b/docs/api.md
index f3ad19e..da172d7 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -1323,6 +1323,9 @@ to turn it on, and note that doing so changes the DPV claims this deployment sig
`pd:` categories it holds — including `pd:CurrentEmployment`, because a contact linked to a company
record carries a structured employer that a free-text box did not).
+Every route is rate limited per *operator* (not per site, so one compromised session cannot hide
+inside its team's traffic) and write bodies are capped at 16 KB.
+
**Auth is a session cookie, never an API key.** This is the one authenticated surface that refuses
`Authorization: Bearer `. A `clk_` key authorizes aggregate analytics and is meant to be
handed out — to agents, to a public demo dashboard — and contact PII is not something that survives
@@ -1342,7 +1345,13 @@ Lists contacts, newest first. `status` ∈ `lead \| active \| archived`; `q` is
match over name/email/company (LIKE metacharacters are escaped, so `q=%` matches a literal `%`). The
company it matches on is the **resolved** one, so searching a company name finds the contacts linked
to it as well as those carrying it as free text. `limit` defaults to 25, max 100. Returns
-`{ contacts: [...], total }`.
+`{ contacts: [...], total, role }`.
+
+`role` is the team role this request was authorized under. It is on the list responses because a
+client has no other way to learn it: `GET /api/auth/me` reports a role per *team*, and no
+session-reachable route says which team owns a given site, so a UI deciding whether to offer the
+admin-only delete and export could otherwise only guess. `offset` is capped at 100,000 — SQLite walks
+every skipped row, so an unbounded one is a full table scan.
### `POST /api/crm/contacts?site_id` (session, analyst)
@@ -1413,7 +1422,7 @@ Name uniqueness is an **exact** match: names are displayed as typed, so they are
the index. Use `domain` if you want a case-insensitive identity key.
`GET` takes `status`, `q` (substring over name/domain), `limit`, `offset` and returns
-`{ companies: [...], total }`.
+`{ companies: [...], total, role }`, with `role` as on the contacts list.
### `GET`/`PATCH /api/crm/companies/:id?site_id` (session, analyst)
@@ -1465,7 +1474,9 @@ reading as the account's traffic when it is one person's. `contacts_truncated` i
capped fan-out is a lower bound, not a total. `visitor_hashes` is contacts multiplied by their live
salt windows — a linkage-breadth number, **not** a headcount; `contacts_linked` is the headcount.
When nothing is linked the response is `{ ..., "linked": false, "reason": "no_linked_contacts" }`
-rather than zeroes that would read as "this account did nothing".
+rather than zeroes that would read as "this account did nothing". When the fan-out was capped the
+reason is `none_linked_within_cap` instead — with contacts left unexamined, "nobody is linked" is a
+claim about rows nothing looked at.
There is **no company export**. A data-subject export is per person by definition; a company-wide one
would be a bulk PII dump with no data-protection meaning. Use `/companies/:id/contacts` and then the
diff --git a/packages/shared/src/crm.ts b/packages/shared/src/crm.ts
index 60da00c..6d53bb3 100644
--- a/packages/shared/src/crm.ts
+++ b/packages/shared/src/crm.ts
@@ -93,6 +93,9 @@ export const ContactUpdateSchema = v.pipe(
* thing enforcing it, so the server imports these rather than repeating the numbers next to a second
* copy that can drift. */
export const CRM_MAX_PAGE = 100;
+
+/** How deep a CRM list may be paged. */
+export const CRM_MAX_OFFSET = 100_000;
export const CRM_DEFAULT_PAGE = 25;
const pageBounds = {
@@ -106,8 +109,17 @@ const pageBounds = {
v.maxValue(CRM_MAX_PAGE),
),
),
+ /** Bounded at both ends. SQLite walks every skipped row, so an unbounded `offset` is a full
+ * table scan and the natural shape of a page-by-page bulk read of the whole contact list. */
offset: v.optional(
- v.pipe(v.string(), v.transform(Number), v.number(), v.integer(), v.minValue(0)),
+ v.pipe(
+ v.string(),
+ v.transform(Number),
+ v.number(),
+ v.integer(),
+ v.minValue(0),
+ v.maxValue(CRM_MAX_OFFSET),
+ ),
),
};