feat(edge): optional pre-chat identification gate (off by default) - #52
Open
lonormaly wants to merge 1 commit into
Open
feat(edge): optional pre-chat identification gate (off by default)#52lonormaly wants to merge 1 commit into
lonormaly wants to merge 1 commit into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.jshas no pre-chat gate of any kind (grepped the whole tree forrequireEmail/preChat/pre-chat/askEmail/identifyacross*.ts,*.js,*.mdx,*.md,*.bru— zero hits).FormSpeclead 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
showFormcard renderer with the existingFormFieldtypes.showFormgained one optional parameter (a submit handler) and one optionaldescriptionline. 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 inensureTopicon 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/leaddelivers, 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
SessionDOis where the session already lives, it's strongly consistent, and it's read on every chat turn anyway. The identity rides the existingPOST /contextbody (which already carriestenantId/siteIdwrite-once) — so the gate costs no new route and no extra subrequest. It's stored write-once, matching thetenantIdposture: 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.comis posted into the topic the moment it's created, above the visitor's first message. Deliberately✉️and not👤—👤already prefixes visitor messages inchat.ts, and an operator shouldn't have to guess whether the email was something the visitor said.It also rides
POST /api/operator/handoffsinbox rows, so the operator app can show who's waiting.3. No new route — the gate lives on
/api/chatThe 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/chatalso 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/chatis 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">acceptsa@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()returnsnullfor a tenant with no Telegram creds (graceful degradation), butGET /api/widget/configreads the raw KV config. Left alone, a Telegram-less self-host would render a gate that let everyone through. The enforcement now falls back toreadTenantConfigon 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
publicWidgetConfigprojectsidentifyonly whenrequire === "email". Otherwise the key isundefined, whichJSON.stringifydrops — so an unconfigured tenant's boot JSON has noidentifykey at all. A test asserts the string"identify"doesn't appear in the response.handleChatcompares one field. No extra work, no extra subrequest, no behaviour change when unset.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.jsandorigin/master'swidget.jsside by side against an identical stubbed edge, as a fresh visitor, and diffed what each actually sent and rendered:master/api/chatbody{tenantId, message, history}"Type a message…"6. Privacy — stated plainly in the docs
docs/security.mdxgains 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 ownlocalStorage), 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
widget.jsfrom this release or newer. An older cached widget never renders the card. Documented in the reference page.403 identity_requiredraises the card even if the boot config said nothing — so the server always wins. (Verified in the browser: simulated a widget whose boot config omittedidentifywhile the edge still refused; the card appeared, the composer locked, and the re-send succeeded.)Try it by hand
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, the403, 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") · rootREADME.md·packages/widget/README.md·services/edge/README.md·api-collection/openapi.yaml(IdentifySpec+VisitorIdentityschemas, the403, theidentifyprojection — every$refverified to resolve) ·chat.bru,config-set.bru,config-get.bru·CHANGELOG.md[Unreleased].Gates — verbatim
bun run check(typecheck · lint · test):bun run format:check:CI on this PR
check— success.vuln-scan / osv-scan— failure, and it is pre-existing. It is red onmastertoo (run30347442261, an hour before this branch existed) for the identical single finding:This PR adds no dependencies and does not touch
bun.lock. The fix is asharpbump to0.35.0, which belongs in its own change.193 tests on
master→ 214 here: 21 new inservices/edge/test/prechat-identify.test.ts, covering the validator, off-by-default (projection and chat route), the403with the model provably not called, malformed-identity rejection, session persistence across turns, the Telegram-less enforcement path,SessionDOwrite-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
globalThis.fetchfor Telegram, matchingedge.test.ts. The Telegram behaviour asserted is the request Krispy sends; I did not watch a message land in a real supergroup.chat.brunow sendsidentity, which an un-gated tenant ignores, so its existing 200 assertion should still hold; I reasoned that from the code rather than running it.apps/docs.theme.greeting.open()checks!savedMsgs.lengthafteradd("bot", greeting)has already persisted the greeting intosavedMsgs. Confirmed by runningorigin/master's widget in a browser, not by reading. The gate's ownrenderStarters()call inherits the same condition, so it behaves consistently with the rest of the widget.🤖 Generated with Claude Code