Skip to content

Commit b8469ef

Browse files
codeyogi911claude
andcommitted
feat: optional verified customer identity via Shopify customer-account sign-in
Anonymous visitors keep the progressive contact flow and knowledge-grounded answers; a customer who signs in with the deployment's Shopify store account gets a verified rail: no contact card, tickets filed under the verified identity, and order questions answered without an order number. - New identity module: discovery-resolved Customer Account API endpoints, authorization-code flow with PKCE for a public client, signed single-use login transactions, HMAC-signed short-lived session cookies, and a bounded customer context read (profile plus five recent orders with status and tracking) validated against the 2026-07 schema. Provider failures are typed 'unavailable'; every sign-in failure path lands back on the portal as anonymous. - /auth/shopify/start, /callback, and /logout routes on the public surface, config-gated by SHOPIFY_CUSTOMER_CLIENT_ID plus the shop domain. - The assistant treats a store-account session as verified contact on file — the card never appears — and gains list_my_orders for signed-in callers. The session cookie re-arrives on every WebSocket reconnect, so verified identity survives connection drops by transport. The signed-in prompt replaces the ask-for-the-number instruction instead of contradicting it. - Sign-in affordance on the assistant homepage; deployment guide section for creating the Customer Account API client. - Coverage: identity module unit tests, auth route tests, agent tests for the verified session and tampered tokens, and a real-model eval case for the signed-in order flow (passes; the pre-existing unfamiliar-product how-to case remains the known flaky one). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f562e34 commit b8469ef

15 files changed

Lines changed: 869 additions & 9 deletions

apps/desk/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@
4545
},
4646
"WHATSAPP_WABA_ID": {
4747
"description": "The WhatsApp Business Account ID accepted by this deployment's webhook."
48+
},
49+
"SHOPIFY_CUSTOMER_CLIENT_ID": {
50+
"description": "Optional Customer Account API public client ID enabling store-account sign-in on the support portal."
4851
}
4952
}
5053
},

apps/desk/scripts/run-voice-evals.mjs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,27 @@ try {
268268
assertNoRepeatedSentence(outageReply, 'order outage reply')
269269
console.log(`PASS order_lookup_unavailable: ${outageReply}`)
270270

271+
// A signed-in store customer asking about "my order" without a number is
272+
// served from list_my_orders — never asked to type the order number first.
273+
const signedInOrder = await turn(
274+
[{ role: 'user', content: 'Where is my order?' }],
275+
{ fixtures: [orderFixture] },
276+
{ signedIn: true, stream: true },
277+
)
278+
const signedInReply = String(signedInOrder.text ?? '')
279+
const signedInTools = Array.isArray(signedInOrder.toolCalls) ? signedInOrder.toolCalls : []
280+
assert(
281+
signedInTools.some((call) => call?.name === 'list_my_orders'),
282+
`signed-in order question must call list_my_orders: ${JSON.stringify(signedInOrder)}`,
283+
)
284+
assert(/4021/.test(signedInReply), `signed-in reply should reference the caller's order: ${signedInReply}`)
285+
assert(
286+
!/what is the order number|order number from your confirmation/i.test(signedInReply),
287+
`signed-in caller must not be asked for the number first: ${signedInReply}`,
288+
)
289+
assertNoRepeatedSentence(signedInReply, 'signed-in order reply')
290+
console.log(`PASS signed_in_order_list: ${signedInReply}`)
291+
271292
// Knowledge-grounded answering: the assistant must consult the help centre
272293
// and answer strictly from article content.
273294
const kbArticle = {

apps/desk/scripts/voice-eval-worker.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export default {
4343
kb?: unknown
4444
stream?: unknown
4545
contact?: unknown
46+
signedIn?: unknown
4647
} | null
4748
const messages = messagesFrom(body?.messages)
4849
if (!messages) return Response.json({ error: 'invalid_messages' }, { status: 400 })
@@ -54,8 +55,10 @@ export default {
5455
: null
5556
// Mirrors production's deferred identity: cases opt into the anonymous
5657
// branch with contact: false, which swaps the ticket/order tools for
57-
// request_contact and flips the prompt branch.
58+
// request_contact and flips the prompt branch. signedIn: true mirrors a
59+
// store-account session, which adds list_my_orders.
5860
const contactPresent = body?.contact !== false
61+
const signedInCase = body?.signedIn === true && contactPresent
5962

6063
const latest = messages.at(-1)
6164
const directResponse = latest?.role === 'user' && typeof latest.content === 'string'
@@ -75,7 +78,7 @@ export default {
7578
reasoning_effort: null,
7679
chat_template_kwargs: { enable_thinking: false },
7780
}),
78-
system: voiceAgentSystemPrompt('Example Company', { orders: Boolean(ordersCase), contact: contactPresent }),
81+
system: voiceAgentSystemPrompt('Example Company', { orders: Boolean(ordersCase), contact: contactPresent, signedIn: signedInCase }),
7982
messages: prepareVoiceModelMessages(messages as Array<{ role: 'user' | 'assistant'; content: string }>),
8083
tools: {
8184
// Mirrors production: the help-centre tool is always registered.
@@ -92,6 +95,13 @@ export default {
9295
return articles.length > 0 ? { status: 'ok', articles } : { status: 'no_match' }
9396
},
9497
}),
98+
...(signedInCase && ordersCase ? {
99+
list_my_orders: tool({
100+
description: "List the signed-in caller's most recent orders: names, dates, payment and fulfillment status, totals, and tracking. Use when they ask about an order without giving a number, then confirm which order they mean. Returns only the signed-in caller's own orders.",
101+
inputSchema: z.object({}),
102+
execute: async () => ({ status: 'ok', orders: Array.isArray(ordersCase.fixtures) ? ordersCase.fixtures : [] }),
103+
}),
104+
} : {}),
95105
...(ordersCase && contactPresent ? {
96106
get_order_status: tool({
97107
description: "Look up one order by the caller's order number. The server matches the number together with this session's contact email and returns not_found unless both match; it never reveals whether a number exists for a different email.",

apps/desk/src/env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export type Env = Cloudflare.Env & {
3636
SHOPIFY_CLIENT_SECRET?: string
3737
/** Legacy custom-app Admin token; still honored as an alternative to the client credentials grant. */
3838
SHOPIFY_ADMIN_TOKEN?: string
39+
/** Customer Account API public client ID enabling optional customer sign-in on the support portal. */
40+
SHOPIFY_CUSTOMER_CLIENT_ID?: string
3941
/** Test-only deterministic voice verification code; never bind this in a deployed environment. */
4042
VOICE_TEST_OTP_CODE?: string
4143
/** Meta callback token used only for the WhatsApp webhook GET challenge. */

0 commit comments

Comments
 (0)