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 = (