Skip to content

feat(edge): optional pre-chat identification gate (off by default) - #52

Open
lonormaly wants to merge 1 commit into
masterfrom
feat/prechat-identify
Open

feat(edge): optional pre-chat identification gate (off by default)#52
lonormaly wants to merge 1 commit into
masterfrom
feat/prechat-identify

Conversation

@lonormaly

@lonormaly lonormaly commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

The gap

A self-host wants the chat available to people who are not signed in, but wants an address before the conversation starts — so an answer can still reach someone who leaves mid-chat. Krispy can't do that today.

What exists and why it doesn't cover it:

  • packages/widget/widget.js has no pre-chat gate of any kind (grepped the whole tree for requireEmail / preChat / pre-chat / askEmail / identify across *.ts, *.js, *.mdx, *.md, *.bru — zero hits).
  • The FormSpec lead form exists, but the model raises it mid-conversation via [!FORM:<id>]. It is not a door in front of the first message.

What this adds

An optional, off-by-default identification gate, configured alongside the rest of the tenant config:

{
  "identify": {
    "require": "email",
    "title": "Before we start",
    "description": "So we can reply if you head off.",
    "collectName": true
  }
}

When required, the widget locks its composer and shows a small card instead. Once an address is given, everything proceeds normally.

Design decisions, and the alternatives I rejected

1. It reuses the lead machinery for the UI — but deliberately not for delivery

The brief asked me not to reinvent lead capture. I didn't, on the widget side: the gate renders through the existing showForm card renderer with the existing FormField types. showForm gained one optional parameter (a submit handler) and one optional description line. There is no second form engine.

But the gate does not post to /api/lead, and that deserves the paragraph the brief asked for.

deliverLead() fans out to two channels. The Telegram half posts into the visitor's topic — and at gate time that topic does not exist yet. Topics are created lazily in ensureTopic on the first chat message, so a lead posted at gate time silently drops the exact half an operator needs. The email half would fire a lead email for every visitor who types an address before saying anything, which is noise, not a lead.

More fundamentally: /api/lead delivers, it doesn't persist. The requirement is that the identity rides with the conversation, which is a storage problem, not a delivery one.

So: same renderer, same field types, different destination. If a maintainer wants the address also fanned out to connectors, that's a small addition on top of this — but it should be opt-in, not the default.

2. Where the identity lives — the SessionDO, and the topic's first message

The brief offered three options. I took two, and here's the reasoning:

The SessionDO is where the session already lives, it's strongly consistent, and it's read on every chat turn anyway. The identity rides the existing POST /context body (which already carries tenantId/siteId write-once) — so the gate costs no new route and no extra subrequest. It's stored write-once, matching the tenantId posture: the first address a session presents is the one the operator sees, and a replayed or hijacked post can't overwrite it.

The topic's first message is what makes an operator able to actually reply. ✉️ Dana · dana@example.com is posted into the topic the moment it's created, above the visitor's first message. Deliberately ✉️ and not 👤👤 already prefixes visitor messages in chat.ts, and an operator shouldn't have to guess whether the email was something the visitor said.

It also rides POST /api/operator/handoffs inbox rows, so the operator app can show who's waiting.

3. No new route — the gate lives on /api/chat

The alternative was POST /api/identify. I skipped it: a new route means a new Bruno request, a new OpenAPI path, new docs, and a second place to get auth wrong — to move a field the chat body can already carry.

Riding /api/chat also has a property I think is genuinely better: the identity and the conversation are atomic. There's no orphaned identity record for a visitor who filled the form and never spoke — which is also the better privacy answer.

The cost, stated honestly: an address typed but never followed by a message isn't captured. Since there's no conversation to answer in that case, I judged that acceptable.

4. Validation is on both sides, and the server is the one that counts

The widget check is a courtesy so the visitor hears "no" immediately. POST /api/chat is the gate: no usable identity on a tenant that requires one → 403 { "error": "identity_required" }, returned before the model runs (asserted by a test that fails if the AI is called).

normalizeIdentity() is pure and unit-tested: trims, lowercases, caps at 200 chars, and requires a dotted domain. That last one is deliberate — <input type="email"> accepts a@b, and an address that can't receive a reply is worse than no address at all. Anything malformed is treated as absent, not as an error.

One hole I found and closed: getTenant() returns null for a tenant with no Telegram creds (graceful degradation), but GET /api/widget/config reads the raw KV config. Left alone, a Telegram-less self-host would render a gate that let everyone through. The enforcement now falls back to readTenantConfig on that path only — one extra KV read, on a path that's already degraded. There's a test for it.

5. Off by default — and structurally, not by convention

  • publicWidgetConfig projects identify only when require === "email". Otherwise the key is undefined, which JSON.stringify drops — so an unconfigured tenant's boot JSON has no identify key at all. A test asserts the string "identify" doesn't appear in the response.
  • handleChat compares one field. No extra work, no extra subrequest, no behaviour change when unset.
  • Widget: every new code path is behind identifySpec !== null, which only the projection can set.

I verified this in a browser rather than by reading the code. I ran this branch's widget.js and origin/master's widget.js side by side against an identical stubbed edge, as a fresh visitor, and diffed what each actually sent and rendered:

master this branch
/api/chat body {tenantId, message, history} identical
bubbles sys, greeting, me, bot identical
composer enabled, "Type a message…" identical

6. Privacy — stated plainly in the docs

docs/security.mdx gains a "the pre-chat gate" section with a table of every place the address lands (SessionDO storage; the Telegram topic; operator inbox responses; the visitor's own localStorage), and states that it is not written to KV, not emailed, not sent to any connector, and not part of the AI's prompt.

It also says the two things an operator will otherwise learn the hard way: the Telegram copy outlives everything (deleting the DO doesn't delete a message in your group — a deletion request means deleting the topic), and a self-host is the controller of this data, so the lawful basis is theirs to establish.

Compatibility

  • Every existing install: unchanged, verified as above.
  • Turning the gate on requires a widget.js from this release or newer. An older cached widget never renders the card. Documented in the reference page.
  • The current widget also self-heals: a 403 identity_required raises the card even if the boot config said nothing — so the server always wins. (Verified in the browser: simulated a widget whose boot config omitted identify while the edge still refused; the card appeared, the composer locked, and the re-send succeeded.)

Try it by hand

# 1. turn the gate on
curl -X POST "$KRISPY_API/api/tenant/config" \
  -H "x-tenant-sync-secret: $TENANT_SYNC_SECRET" -H 'content-type: application/json' \
  -d '{"tenantId":"self","config":{"identify":{"require":"email","description":"So we can reply if you head off."}}}'

# 2. the widget now sees it
curl "$KRISPY_API/api/widget/config?t=self"
# → { ..., "identify": { "require": "email", "description": "..." } }

# 3. a turn with no address is refused
curl -X POST "$KRISPY_API/api/chat" -H 'content-type: application/json' \
  -d '{"sessionId":"demo-1","tenantId":"self","message":"hi"}'
# → 403 {"error":"identity_required"}

# 4. a malformed one is still no address (a client check is a suggestion)
curl -X POST "$KRISPY_API/api/chat" -H 'content-type: application/json' \
  -d '{"sessionId":"demo-1","tenantId":"self","message":"hi","identity":{"email":"a@b"}}'
# → 403

# 5. a real one passes — and opens the topic with "✉️ dana@example.com"
curl -X POST "$KRISPY_API/api/chat" -H 'content-type: application/json' \
  -d '{"sessionId":"demo-1","tenantId":"self","message":"hi","identity":{"email":"dana@example.com"}}'
# → 200; a later turn from demo-1 passes without re-sending it

# 6. turn it off again — nothing else changes
curl -X POST "$KRISPY_API/api/tenant/config" \
  -H "x-tenant-sync-secret: $TENANT_SYNC_SECRET" -H 'content-type: application/json' \
  -d '{"tenantId":"self","config":{"identify":{"require":"none"}}}'

In the widget: bun run dev:edge + bun run dev:widget, open the demo, click the launcher. The composer is disabled with "Enter your email to start…" until the card is filled.

Docs + contract (per AGENTS.md §7)

reference/tenant-config.mdx (IdentifySpec + two new write-cap rows) · reference/edge-routes.mdx (the gate, the 403, the DO surface) · security.mdx (what's stored, and the sanitization entry) · guides/lead-forms-and-connectors.mdx ("not a form: the pre-chat gate") · root README.md · packages/widget/README.md · services/edge/README.md · api-collection/openapi.yaml (IdentifySpec + VisitorIdentity schemas, the 403, the identify projection — every $ref verified to resolve) · chat.bru, config-set.bru, config-get.bru · CHANGELOG.md [Unreleased].

Gates — verbatim

bun run check (typecheck · lint · test):

$ bun --filter '@krispy*/*' typecheck
@krispyai/cli typecheck: Exited with code 0
@krispy/edge typecheck: Exited with code 0

$ oxlint
(exit 0; total warning count unchanged from master: 184 → 184, no errors)

@krispy/edge test:  214 pass
@krispy/edge test:  0 fail
@krispy/edge test: Exited with code 0
@krispyai/cli test:  14 pass
@krispyai/cli test:  0 fail
@krispyai/cli test: Exited with code 0

bun run format:check:

$ oxfmt --check .
Checking formatting...
All matched files use the correct format.
Finished in 1022ms on 118 files using 10 threads.

CI on this PR

checksuccess. vuln-scan / osv-scanfailure, and it is pre-existing. It is red on master too (run 30347442261, an hour before this branch existed) for the identical single finding:

Total 1 package affected by 1 known vulnerability (0 Critical, 1 High, 0 Medium, 0 Low, 0 Unknown) from 1 ecosystem.
| https://osv.dev/GHSA-f88m-g3jw-g9cj | 7.0 | npm | sharp | 0.34.5 | 0.35.0 | bun.lock |

This PR adds no dependencies and does not touch bun.lock. The fix is a sharp bump to 0.35.0, which belongs in its own change.


193 tests on master214 here: 21 new in services/edge/test/prechat-identify.test.ts, covering the validator, off-by-default (projection and chat route), the 403 with the model provably not called, malformed-identity rejection, session persistence across turns, the Telegram-less enforcement path, SessionDO write-once + the inbox row, the topic's first message (and its absence on an un-gated session), and both write caps.

What I could not verify

  • Nothing was run against real Cloudflare or real Telegram. Tests use the repo's own Map-backed KV/DO fakes and stub globalThis.fetch for Telegram, matching edge.test.ts. The Telegram behaviour asserted is the request Krispy sends; I did not watch a message land in a real supergroup.
  • The Bruno collection was not executed — no running Worker here. chat.bru now sends identity, which an un-gated tenant ignores, so its existing 200 assertion should still hold; I reasoned that from the code rather than running it.
  • The docs site was not built. I verified the MDX anchors resolve with a script over the headings, but did not run a Next build in apps/docs.
  • One pre-existing bug found, not fixed (out of scope, flagging it): starter chips never render for a tenant that also sets theme.greeting. open() checks !savedMsgs.length after add("bot", greeting) has already persisted the greeting into savedMsgs. Confirmed by running origin/master's widget in a browser, not by reading. The gate's own renderStarters() call inherits the same condition, so it behaves consistently with the rest of the widget.

🤖 Generated with Claude Code

A self-host that keeps the chat open to anonymous visitors, but wants an
address before the conversation starts so an answer can still reach someone
who leaves. New optional `TenantConfig.identify`:

    { require: "email" | "none", title?, description?, collectName? }

OFF by default, structurally: unset (or "none") means the key is absent from
`GET /api/widget/config` entirely and `POST /api/chat` gates nothing, so an
existing embed's boot JSON and chat behaviour are byte-for-byte unchanged.

The widget renders the card; the EDGE is the gate. `/api/chat` answers
403 identity_required for a turn with no usable identity, before the model
runs. `identity` ({email, name?}) rides the existing chat body and is
re-validated every turn (trimmed, lowercased, <=200 chars, dotted domain —
stricter than <input type="email">, which accepts "a@b"); malformed counts
as absent. The first valid identity is stored write-once on the session's
SessionDO via the `POST /context` body that already runs each turn — no new
route, no extra subrequest.

Not wired into /api/lead on purpose: the gate fires before a topic exists,
so the Telegram half of the lead fan-out would no-op and the email half
would mail a lead for someone who hasn't spoken yet. Instead the address
reaches the operator where they actually reply — the first message of the
visitor's Telegram topic (✉️, distinct from the 👤 that prefixes visitor
messages), plus /api/operator/handoffs inbox rows.

The widget reuses the lead-form card renderer rather than adding a second
form engine (showForm gained an optional submit handler + description line):
composer disabled, starter chips withheld, card after any scripted opener.
The address is kept in localStorage and re-sent every post, so a DO hiccup
can't lock a visitor out — and a 403 raises the card even when the boot
config said nothing, so the server always wins.

Write caps: 400 identify_require_invalid (an unknown `require` is rejected,
not silently treated as off), 413 identify_text_too_large (>500 chars).

Docs: tenant-config (IdentifySpec), edge-routes, security (what is stored
and where — this is personal data on every self-host that turns it on),
lead-forms guide, both READMEs, openapi.yaml + the three .bru requests,
CHANGELOG. 21 new tests in services/edge/test/prechat-identify.test.ts.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant