diff --git a/app/(capture)/checkin/actions.ts b/app/(capture)/checkin/actions.ts
index 9ec3351a8b..8b37f11f21 100644
--- a/app/(capture)/checkin/actions.ts
+++ b/app/(capture)/checkin/actions.ts
@@ -9,8 +9,8 @@ import type { CaptureSubmitResult } from '@/components/crm/leads/capture-form'
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/
/**
- * FRONT DOOR #3 — event / attendance capture. Seals an attendee as a lead and records the attendance
- * tier so the Space can map tier -> lifecycle stage. Attendance is NOT marketing consent, so the lead
+ * FRONT DOOR #3 — event contact capture. Seals a guest as a lead with an event label and tier;
+ * this does not write an event attendance receipt. Attendance is NOT marketing consent, so the lead
* stays sealed 'unknown' (the engine enforces this). Public + unauthenticated: honeypot, rate limit,
* anti-enumeration. Space + event come only from the signed token.
*/
@@ -34,8 +34,8 @@ export async function captureCheckIn(input: {
const done: CaptureSubmitResult = {
ok: true,
- heading: "You're checked in.",
- message: 'Thanks for coming. Good to have you here.',
+ heading: 'Thanks for coming.',
+ message: 'The host has your contact information. Ask your host if you need attendance confirmation.',
}
// Honeypot: same success, no write.
@@ -46,20 +46,31 @@ export async function captureCheckIn(input: {
return { ok: false, error: 'This check-in link is no longer valid. Ask a host for a fresh one.' }
}
- const hdrs = await headers()
- const ip = hdrs.get('x-forwarded-for')?.split(',')[0]?.trim() || hdrs.get('x-real-ip') || 'unknown'
- if (!(await rateLimitOk('lead_checkin', ip, 12, '10 m'))) {
- return { ok: false, error: 'Too many tries. Give it a few minutes and try again.' }
+ const unavailable: CaptureSubmitResult = {
+ ok: false,
+ error: "We couldn't save your details. Please try again or ask the host for help.",
}
- await captureEventLead({
- spaceId: payload.s,
- email,
- phone,
- displayName: name,
- eventTitle: payload.w ?? payload.l ?? null,
- tier: payload.tr ?? 'attended',
- }).catch(() => null)
+ try {
+ const hdrs = await headers()
+ const ip = hdrs.get('x-forwarded-for')?.split(',')[0]?.trim() || hdrs.get('x-real-ip') || 'unknown'
+ if (!(await rateLimitOk('lead_checkin', ip, 12, '10 m'))) {
+ return { ok: false, error: 'Too many tries. Give it a few minutes and try again.' }
+ }
- return done
+ const captured = await captureEventLead({
+ spaceId: payload.s,
+ email,
+ phone,
+ displayName: name,
+ eventTitle: payload.w ?? payload.l ?? null,
+ tier: payload.tr ?? 'attended',
+ })
+
+ // A null result includes storage/capacity failures. A contact receipt is not an attendance ledger.
+ return captured ? done : unavailable
+ } catch {
+ // Keep infrastructure failures generic, without revealing whether this address already exists.
+ return unavailable
+ }
}
diff --git a/app/(capture)/checkin/page.tsx b/app/(capture)/checkin/page.tsx
index ab94eb7239..f7db344491 100644
--- a/app/(capture)/checkin/page.tsx
+++ b/app/(capture)/checkin/page.tsx
@@ -6,8 +6,8 @@ import { CaptureForm } from '@/components/crm/leads/capture-form'
import { captureCheckIn } from './actions'
// FRONT DOOR #3 — event / attendance capture. A host points attendees here (a printed QR, a link on
-// the screen). Leaving a name + email checks them in and drops them into the Space CRM with the event
-// as their entry point. Not marketing consent, so it never promises email. Noindex (transactional).
+// the screen). Name + email are captured in the Space CRM with the event label; this is not the
+// event attendance ledger. Not marketing consent, so it never promises email. Noindex (transactional).
export const metadata: Metadata = {
title: 'Check in',
robots: { index: false },
@@ -37,7 +37,7 @@ export default async function CheckInPage({ searchParams }: { searchParams: Prom
Checking in just tells the host you were here. We won't add you to any email list for it.>
+ <>Your details go to the host. We won't add you to any email list for it.>
}
/>
diff --git a/app/(capture)/checkin/receipt.test.ts b/app/(capture)/checkin/receipt.test.ts
new file mode 100644
index 0000000000..b273b1cd68
--- /dev/null
+++ b/app/(capture)/checkin/receipt.test.ts
@@ -0,0 +1,83 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const fx = vi.hoisted(() => ({
+ capture: vi.fn(), headers: vi.fn(), rateLimit: vi.fn(),
+}))
+vi.mock('next/headers', () => ({ headers: fx.headers }))
+vi.mock('@/lib/rate-limit', () => ({ rateLimitOk: fx.rateLimit }))
+vi.mock('@/lib/crm/lead-capture', async (importActual) => ({
+ ...await importActual(),
+ captureEventLead: fx.capture,
+}))
+import { captureCheckIn } from '@/app/(capture)/checkin/actions'
+import { makeLeadLinkPayload, signLeadLink } from '@/lib/crm/lead-links'
+
+const blank = { name: ' Jo ', email: ' JO@example.com ', phone: '', company: '' }
+const token = () => signLeadLink(makeLeadLinkPayload({ s: 'space-1', d: 'event', w: 'Sunday Sit', tr: 'vip' }))
+
+beforeEach(() => {
+ process.env.OPTIN_CONFIRM_SECRET = 'test-guest-receipt-secret-0123456789'
+ vi.resetAllMocks()
+ fx.headers.mockResolvedValue(new Map([['x-forwarded-for', '192.0.2.1, 192.0.2.2']]))
+ fx.rateLimit.mockResolvedValue(true)
+ fx.capture.mockResolvedValue({ contactId: 'contact-1', firstTouch: true })
+})
+
+describe('guest event contact receipt', () => {
+ it('describes the contact receipt without claiming verified attendance', async () => {
+ const result = await captureCheckIn({ ...blank, token: token() })
+ expect(result).toEqual({ ok: true, heading: 'Thanks for coming.', message: 'The host has your contact information. Ask your host if you need attendance confirmation.' })
+ expect(fx.capture).toHaveBeenCalledWith({ spaceId: 'space-1', email: 'jo@example.com', phone: null, displayName: 'Jo', eventTitle: 'Sunday Sit', tier: 'vip' })
+ expect(fx.rateLimit).toHaveBeenCalledWith('lead_checkin', '192.0.2.1', 12, '10 m')
+ })
+ it('gives existing and new contacts the same receipt', async () => {
+ const created = await captureCheckIn({ ...blank, token: token() })
+ fx.capture.mockResolvedValue({ contactId: 'contact-2', firstTouch: false })
+ expect(await captureCheckIn({ ...blank, token: token() })).toEqual(created)
+ })
+ it('returns a visible generic retry error on a null save or capacity refusal', async () => {
+ fx.capture.mockResolvedValue(null)
+ expect(await captureCheckIn({ ...blank, token: token() })).toEqual({ ok: false, error: "We couldn't save your details. Please try again or ask the host for help." })
+ })
+ it('uses that same generic error for a rejected save without leaking provider details', async () => {
+ fx.capture.mockRejectedValue(new Error('private storage detail'))
+ expect(await captureCheckIn({ ...blank, token: token() })).toEqual({ ok: false, error: "We couldn't save your details. Please try again or ask the host for help." })
+ })
+ it('returns a retry result if request headers fail', async () => {
+ fx.headers.mockRejectedValue(new Error('request unavailable'))
+ expect((await captureCheckIn({ ...blank, token: token() })).ok).toBe(false)
+ expect(fx.capture).not.toHaveBeenCalled()
+ })
+ it('returns a retry result if rate-limit storage rejects', async () => {
+ fx.rateLimit.mockRejectedValue(new Error('rate storage unavailable'))
+ expect((await captureCheckIn({ ...blank, token: token() })).ok).toBe(false)
+ expect(fx.capture).not.toHaveBeenCalled()
+ })
+ it('keeps rate-limited attempts refused without a capture', async () => {
+ fx.rateLimit.mockResolvedValue(false)
+ expect(await captureCheckIn({ ...blank, token: token() })).toEqual({ ok: false, error: 'Too many tries. Give it a few minutes and try again.' })
+ expect(fx.capture).not.toHaveBeenCalled()
+ })
+ it('keeps the intentional honeypot success identical and skips token, headers and storage', async () => {
+ const success = await captureCheckIn({ ...blank, token: token() })
+ vi.clearAllMocks()
+ expect(await captureCheckIn({ ...blank, token: 'forged', company: 'bot' })).toEqual(success)
+ expect(fx.capture).not.toHaveBeenCalled()
+ expect(fx.headers).not.toHaveBeenCalled()
+ expect(fx.rateLimit).not.toHaveBeenCalled()
+ })
+ it('refuses forged tokens without capture', async () => {
+ expect((await captureCheckIn({ ...blank, token: 'forged' })).ok).toBe(false)
+ expect(fx.capture).not.toHaveBeenCalled()
+ })
+ it('refuses other signed doors without capture', async () => {
+ const wrongDoor = signLeadLink(makeLeadLinkPayload({ s: 'space-1', d: 'share_back' }))
+ expect((await captureCheckIn({ ...blank, token: wrongDoor })).ok).toBe(false)
+ expect(fx.capture).not.toHaveBeenCalled()
+ })
+ it('keeps invalid email validation before all storage calls', async () => {
+ expect((await captureCheckIn({ ...blank, email: 'invalid', token: token() })).ok).toBe(false)
+ expect(fx.capture).not.toHaveBeenCalled()
+ expect(fx.rateLimit).not.toHaveBeenCalled()
+ })
+})
diff --git a/docs/ledger/rows/SHARE-GUEST-RECEIPT.json b/docs/ledger/rows/SHARE-GUEST-RECEIPT.json
new file mode 100644
index 0000000000..d9af87f437
--- /dev/null
+++ b/docs/ledger/rows/SHARE-GUEST-RECEIPT.json
@@ -0,0 +1,19 @@
+{
+ "id": "SHARE-GUEST-RECEIPT",
+ "title": "Guest check-in shows a truthful contact receipt and recoverable save failures",
+ "lane": "live",
+ "priority": "P2",
+ "size": "S",
+ "status": "open",
+ "source": {
+ "file": "app/(capture)/checkin/actions.ts",
+ "ref": "Sharing-system audit QR-NFC-03, rechecked on main 21f1e340, 2026-11-04."
+ },
+ "detail": "The public signed event contact door previously returned success after null or rejected captureEventLead. Its CRM engine can return null for capacity or storage failures and its non-null result proves a Space-scoped contact exists, not an event attendance receipt. Local candidate returns a generic retry result for null, rejected capture, header and rate-store exceptions, keeps the intentional honeypot uniform success before token/storage, and describes the host contact receipt without claiming attendance. Signed Space/event-label/tier binding and CRM consent/suppression behavior are unchanged. Acceptance: successful existing/new contacts share one truthful receipt; null/rejected failures never confirm saving; honeypot skips token/headers/storage; invalid and wrong-door tokens remain refused; no marketing opt-in, event attendance ledger or reward write is introduced. Daniel specifically approved the exact regression review patch on 2026-11-04; its 11 permanent action checks are applied. Still open pending required exact-head CI/preview, release integration and production verification.",
+ "verify": {
+ "kind": "manual",
+ "checked": "2026-11-04",
+ "evidence": "On isolated 21f1e340 candidate, all 85 tests across seven focused suites pass, including the exact 11-test regression patch Daniel approved. The temporary pristine-base negative control fails the five targeted defects while six controls pass. Focused lint/diff and static backlog checks pass. No real capture or production write was performed; no production proof is claimed."
+ },
+ "wave": "W7"
+}