From 9b9da0688096ef2fe19534168bfcc0282fdb7d0e Mon Sep 17 00:00:00 2001 From: Tony Rivera Date: Wed, 1 Jul 2026 21:58:06 -0400 Subject: [PATCH] fix: notify per anonymous viewer, not once per tracked link For a tracked link without email verification, every anonymous viewer was keyed as the constant "anon". recordOpen decides "first open" per viewer, so after the very first open the owner's notification never fired again, no matter how many different people opened the link. Give each browser a stable id via a first-party sentou_vid cookie and key anonymous viewers as "anon:". The viewer page mints the id when the cookie is absent (a server component cannot set cookies, so the tracking script persists it client-side) and passes it into the tracking token. recordOpen's existing per-viewer logic then notifies once per distinct browser while a refresh by the same browser still dedupes; verified viewers keep being keyed by email. The id is a dedup bucket, not an identity, so a client-set cookie is fine. displayViewer renders "anon:" as anon-. Tests: trackingContext keys distinct browsers distinctly and ignores the id for a verified viewer; recordOpen treats two browsers as two first opens and a repeat as not-first; the viewer page persists the cookie. Live-smoked the cookie round-trip: no cookie mints a fresh id, a sent cookie is reused (refresh dedupes), and a new visitor gets a different id. --- app/(dashboard)/routes/[id]/page.tsx | 5 ++++- app/v/[slug]/page.test.ts | 6 +++++- app/v/[slug]/page.tsx | 13 ++++++++++++- lib/notifications.test.ts | 20 +++++++++++++++++++- lib/tracking-context.test.ts | 14 ++++++++++++++ lib/tracking-context.ts | 13 ++++++++++--- 6 files changed, 64 insertions(+), 7 deletions(-) diff --git a/app/(dashboard)/routes/[id]/page.tsx b/app/(dashboard)/routes/[id]/page.tsx index 26adb01..6f636e8 100644 --- a/app/(dashboard)/routes/[id]/page.tsx +++ b/app/(dashboard)/routes/[id]/page.tsx @@ -48,7 +48,10 @@ function shortDate(dateStr: string): string { function displayViewer(viewer: string, verifiedEmails: Set): string { if (verifiedEmails.has(viewer)) return viewer; - const prefix = viewer.replace(/[^a-z0-9]/gi, "").slice(0, 8) || viewer.slice(0, 8); + // Anonymous viewers are keyed "anon" (legacy) or "anon:". Strip the prefix + // before shortening so distinct browsers read as distinct anonymous viewers (anon-). + const id = viewer.startsWith("anon:") ? viewer.slice(5) : viewer; + const prefix = id.replace(/[^a-z0-9]/gi, "").slice(0, 8) || "viewer"; return `anon-${prefix}`; } diff --git a/app/v/[slug]/page.test.ts b/app/v/[slug]/page.test.ts index 6bafd7e..363fa93 100644 --- a/app/v/[slug]/page.test.ts +++ b/app/v/[slug]/page.test.ts @@ -134,7 +134,11 @@ describe("viewer page", () => { const token = html.match(/var t="([^"]+)"/)![1]; const claim = verifyTrackToken(token)!; expect(claim.linkId).toBe(link.id); - expect(claim.viewer).toBe("anon"); // no access cookie in this render + // No access cookie in this render, so the viewer is anonymous. It is keyed by a per-browser + // id ("anon:") rather than the bare "anon" so distinct viewers notify independently. + expect(claim.viewer.startsWith("anon")).toBe(true); + // The script also persists that per-browser id as a cookie so a refresh dedupes. + expect(html).toContain("sentou_vid="); }); const formGate = { requireEmail: true, allowedDomains: null, expiresAt: null, revoked: false }; diff --git a/app/v/[slug]/page.tsx b/app/v/[slug]/page.tsx index 151f58e..49f765c 100644 --- a/app/v/[slug]/page.tsx +++ b/app/v/[slug]/page.tsx @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { notFound } from "next/navigation"; import { cookies } from "next/headers"; import { getLinkBySlug } from "@/lib/links"; @@ -360,7 +361,15 @@ export default async function ViewerPage({ // ── Viewer: artifact in sandboxed iframe ─────────────────────────────────── // STRUCTURE: main's DIRECT children must be [maybe-notice-div, iframe, maybe-script]. // No extra wrappers here — tests verify direct siblings to prove sandbox isolation. - const tracking = trackingContext(link, claim); + // + // Per-browser id for anonymous open-notification dedup. A server component cannot set a + // cookie, so mint one when absent, use it for this render's tracking token, and persist it + // client-side in the tracking script below. It is only a dedup bucket (not an identity or a + // security token), so a client-set, non-httpOnly cookie is fine; tampering only affects that + // viewer's own notification dedup. Only meaningful when tracking is on. + const VISITOR_COOKIE = "sentou_vid"; + const visitorId = cookieStore.get(VISITOR_COOKIE)?.value || randomUUID(); + const tracking = trackingContext(link, claim, visitorId); const iframe = (