diff --git a/apps/desk/package.json b/apps/desk/package.json index 47bfddf..f4bbf8f 100644 --- a/apps/desk/package.json +++ b/apps/desk/package.json @@ -45,6 +45,9 @@ }, "WHATSAPP_WABA_ID": { "description": "The WhatsApp Business Account ID accepted by this deployment's webhook." + }, + "SHOPIFY_CUSTOMER_CLIENT_ID": { + "description": "Optional Customer Account API public client ID enabling store-account sign-in on the support portal." } } }, diff --git a/apps/desk/scripts/run-voice-evals.mjs b/apps/desk/scripts/run-voice-evals.mjs index 5d369aa..2755189 100644 --- a/apps/desk/scripts/run-voice-evals.mjs +++ b/apps/desk/scripts/run-voice-evals.mjs @@ -268,6 +268,27 @@ try { assertNoRepeatedSentence(outageReply, 'order outage reply') console.log(`PASS order_lookup_unavailable: ${outageReply}`) + // A signed-in store customer asking about "my order" without a number is + // served from list_my_orders — never asked to type the order number first. + const signedInOrder = await turn( + [{ role: 'user', content: 'Where is my order?' }], + { fixtures: [orderFixture] }, + { signedIn: true, stream: true }, + ) + const signedInReply = String(signedInOrder.text ?? '') + const signedInTools = Array.isArray(signedInOrder.toolCalls) ? signedInOrder.toolCalls : [] + assert( + signedInTools.some((call) => call?.name === 'list_my_orders'), + `signed-in order question must call list_my_orders: ${JSON.stringify(signedInOrder)}`, + ) + assert(/4021/.test(signedInReply), `signed-in reply should reference the caller's order: ${signedInReply}`) + assert( + !/what is the order number|order number from your confirmation/i.test(signedInReply), + `signed-in caller must not be asked for the number first: ${signedInReply}`, + ) + assertNoRepeatedSentence(signedInReply, 'signed-in order reply') + console.log(`PASS signed_in_order_list: ${signedInReply}`) + // Knowledge-grounded answering: the assistant must consult the help centre // and answer strictly from article content. const kbArticle = { diff --git a/apps/desk/scripts/voice-eval-worker.ts b/apps/desk/scripts/voice-eval-worker.ts index d86c50e..3a2da18 100644 --- a/apps/desk/scripts/voice-eval-worker.ts +++ b/apps/desk/scripts/voice-eval-worker.ts @@ -43,6 +43,7 @@ export default { kb?: unknown stream?: unknown contact?: unknown + signedIn?: unknown } | null const messages = messagesFrom(body?.messages) if (!messages) return Response.json({ error: 'invalid_messages' }, { status: 400 }) @@ -54,8 +55,10 @@ export default { : null // Mirrors production's deferred identity: cases opt into the anonymous // branch with contact: false, which swaps the ticket/order tools for - // request_contact and flips the prompt branch. + // request_contact and flips the prompt branch. signedIn: true mirrors a + // store-account session, which adds list_my_orders. const contactPresent = body?.contact !== false + const signedInCase = body?.signedIn === true && contactPresent const latest = messages.at(-1) const directResponse = latest?.role === 'user' && typeof latest.content === 'string' @@ -75,7 +78,7 @@ export default { reasoning_effort: null, chat_template_kwargs: { enable_thinking: false }, }), - system: voiceAgentSystemPrompt('Example Company', { orders: Boolean(ordersCase), contact: contactPresent }), + system: voiceAgentSystemPrompt('Example Company', { orders: Boolean(ordersCase), contact: contactPresent, signedIn: signedInCase }), messages: prepareVoiceModelMessages(messages as Array<{ role: 'user' | 'assistant'; content: string }>), tools: { // Mirrors production: the help-centre tool is always registered. @@ -92,6 +95,13 @@ export default { return articles.length > 0 ? { status: 'ok', articles } : { status: 'no_match' } }, }), + ...(signedInCase && ordersCase ? { + list_my_orders: tool({ + 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.", + inputSchema: z.object({}), + execute: async () => ({ status: 'ok', orders: Array.isArray(ordersCase.fixtures) ? ordersCase.fixtures : [] }), + }), + } : {}), ...(ordersCase && contactPresent ? { get_order_status: tool({ 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.", diff --git a/apps/desk/src/env.ts b/apps/desk/src/env.ts index 9c88abc..6a89beb 100644 --- a/apps/desk/src/env.ts +++ b/apps/desk/src/env.ts @@ -36,6 +36,8 @@ export type Env = Cloudflare.Env & { SHOPIFY_CLIENT_SECRET?: string /** Legacy custom-app Admin token; still honored as an alternative to the client credentials grant. */ SHOPIFY_ADMIN_TOKEN?: string + /** Customer Account API public client ID enabling optional customer sign-in on the support portal. */ + SHOPIFY_CUSTOMER_CLIENT_ID?: string /** Test-only deterministic voice verification code; never bind this in a deployed environment. */ VOICE_TEST_OTP_CODE?: string /** Meta callback token used only for the WhatsApp webhook GET challenge. */ diff --git a/apps/desk/src/identity/shopify-customer.ts b/apps/desk/src/identity/shopify-customer.ts new file mode 100644 index 0000000..0f86772 --- /dev/null +++ b/apps/desk/src/identity/shopify-customer.ts @@ -0,0 +1,374 @@ +/** + * Shopify customer-account sign-in: an optional verified-identity rail for the + * public support surfaces. A signed-in customer skips the contact card and may + * ask about their own orders; anonymous visitors keep the progressive flow. + * + * Mechanics: the OAuth 2.0 authorization-code flow of the Customer Account API + * with a public client and PKCE (S256). Endpoints are resolved through the + * shop's discovery documents rather than hardcoded hosts, and the customer + * data read is a bounded projection — profile plus recent orders — matching + * the read-back standard of the possession-based order lookup. Provider + * failures become typed 'unavailable' results; raw errors never reach the + * model or the customer. + * + * The session and login transaction travel as HMAC-signed tokens in secure + * transport cookies, signed with the deployment's customer-capability secret. + * The access token inside the session belongs to the customer whose browser + * carries the cookie; the signature only prevents forgery and tampering. + */ + +const DISCOVERY_TTL_MS = 10 * 60_000 +const DEFAULT_TIMEOUT_MS = 10_000 +const OAUTH_SCOPE = 'openid email customer-account-api:full' +const ORDER_LIMIT = 5 +const LINE_ITEM_LIMIT = 10 +const TRACKING_LIMIT = 5 + +export const SHOPIFY_CUSTOMER_SESSION_COOKIE = 'able_shopify_customer' +export const SHOPIFY_CUSTOMER_LOGIN_COOKIE = 'able_shopify_login' +/** Login transactions are short-lived: the redirect round-trip only. */ +export const SHOPIFY_LOGIN_TRANSACTION_TTL_SECONDS = 10 * 60 +export const SHOPIFY_CUSTOMER_SESSION_MAX_SECONDS = 60 * 60 + +type ShopifyCustomerEnv = { + SHOPIFY_SHOP_DOMAIN?: string + SHOPIFY_CUSTOMER_CLIENT_ID?: string +} + +export type ShopifyCustomerSession = { + name: string + email: string + accessToken: string + expiresAt: number +} + +export type ShopifyCustomerOrder = { + name: string + processedAt: string + financialStatus: string | null + fulfillmentStatus: string | null + total: { amount: string; currencyCode: string } | null + lineItems: { title: string; quantity: number }[] + tracking: { number: string | null; url: string | null }[] +} + +export type ShopifyCustomerContextResult = + | { status: 'ok'; customer: { name: string; email: string | null; orders: ShopifyCustomerOrder[] } } + | { status: 'unavailable' } + +type LoginTransaction = { state: string; verifier: string; expiresAt: number } + +type Discovery = { authorizationEndpoint: string; tokenEndpoint: string; graphqlEndpoint: string } + +const encoder = new TextEncoder() + +function shopHostname(domain: string | undefined): string | null { + const trimmed = (domain ?? '').trim().replace(/^https?:\/\//i, '').replace(/\/.*$/, '') + return /^[a-z0-9][a-z0-9.-]+$/i.test(trimmed) ? trimmed.toLowerCase() : null +} + +export function shopifyCustomerConfigured(env: ShopifyCustomerEnv): boolean { + return Boolean(shopHostname(env.SHOPIFY_SHOP_DOMAIN) && env.SHOPIFY_CUSTOMER_CLIENT_ID?.trim()) +} + +function logOutcome(outcome: string, detail: Record = {}): void { + // Booleans and HTTP statuses only: no tokens, emails, or customer data. + console.warn(JSON.stringify({ event: 'shopify_customer_identity', outcome, ...detail })) +} + +let cachedDiscovery: { hostname: string; discovery: Discovery; expiresAt: number } | null = null + +export function resetShopifyCustomerDiscoveryCache(): void { + cachedDiscovery = null +} + +async function fetchJson(fetcher: typeof fetch, url: string, init: RequestInit, timeoutMs: number): Promise { + try { + const response = await fetcher(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }) + if (!response.ok) { + logOutcome('http_error', { url: new URL(url).pathname, status: response.status }) + return null + } + return await response.json() + } catch { + logOutcome('network_error', { url: new URL(url).pathname }) + return null + } +} + +async function discover(hostname: string, fetcher: typeof fetch, now: number, timeoutMs: number): Promise { + if (cachedDiscovery && cachedDiscovery.hostname === hostname && cachedDiscovery.expiresAt > now) { + return cachedDiscovery.discovery + } + const [openid, api] = await Promise.all([ + fetchJson(fetcher, `https://${hostname}/.well-known/openid-configuration`, {}, timeoutMs), + fetchJson(fetcher, `https://${hostname}/.well-known/customer-account-api`, {}, timeoutMs), + ]) + const authorizationEndpoint = (openid as Record)?.['authorization_endpoint'] + const tokenEndpoint = (openid as Record)?.['token_endpoint'] + const apiRecord = api as Record | null + const graphqlEndpoint = apiRecord?.['graphql_api'] ?? apiRecord?.['graphql_endpoint'] ?? apiRecord?.['graphqlApi'] + if (typeof authorizationEndpoint !== 'string' || typeof tokenEndpoint !== 'string' || typeof graphqlEndpoint !== 'string') { + logOutcome('discovery_incomplete', { + hasAuthorization: typeof authorizationEndpoint === 'string', + hasToken: typeof tokenEndpoint === 'string', + hasGraphql: typeof graphqlEndpoint === 'string', + }) + return null + } + const discovery = { authorizationEndpoint, tokenEndpoint, graphqlEndpoint } + cachedDiscovery = { hostname, discovery, expiresAt: now + DISCOVERY_TTL_MS } + return discovery +} + +function base64Url(bytes: Uint8Array): string { + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function base64UrlDecode(value: string): Uint8Array | null { + try { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/') + const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4) + return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)) + } catch { + return null + } +} + +async function hmac(secret: string, payload: string): Promise { + const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) + return base64Url(new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(`able:shopify-customer:v1:${payload}`)))) +} + +async function signToken(secret: string, value: unknown): Promise { + const payload = base64Url(encoder.encode(JSON.stringify(value))) + return `v1.${payload}.${await hmac(secret, payload)}` +} + +async function verifyToken(secret: string, token: string): Promise { + const [version, payload, signature] = token.split('.') + if (version !== 'v1' || !payload || !signature) return null + const expected = await hmac(secret, payload) + const a = encoder.encode(signature) + const b = encoder.encode(expected) + if (a.byteLength !== b.byteLength) return null + let mismatch = 0 + for (let index = 0; index < a.byteLength; index++) mismatch |= (a[index] ?? 0) ^ (b[index] ?? 0) + if (mismatch !== 0) return null + const decoded = base64UrlDecode(payload) + if (!decoded) return null + try { + return JSON.parse(new TextDecoder().decode(decoded)) + } catch { + return null + } +} + +export type ShopifyLoginStart = { url: string; transactionToken: string } + +/** + * Build the authorization redirect and the signed single-use transaction that + * the callback must present. The caller stores the transaction token in a + * short-lived cookie for the redirect round-trip. + */ +export async function beginShopifyCustomerLogin( + env: ShopifyCustomerEnv, + input: { redirectUri: string; secret: string }, + options: { fetcher?: typeof fetch; now?: () => number; timeoutMs?: number } = {}, +): Promise { + const hostname = shopHostname(env.SHOPIFY_SHOP_DOMAIN) + const clientId = env.SHOPIFY_CUSTOMER_CLIENT_ID?.trim() + if (!hostname || !clientId || !input.secret) return null + const now = options.now?.() ?? Date.now() + const discovery = await discover(hostname, options.fetcher ?? fetch, now, options.timeoutMs ?? DEFAULT_TIMEOUT_MS) + if (!discovery) return null + + const state = base64Url(crypto.getRandomValues(new Uint8Array(16))) + const verifier = base64Url(crypto.getRandomValues(new Uint8Array(32))) + const challenge = base64Url(new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(verifier)))) + + const url = new URL(discovery.authorizationEndpoint) + url.searchParams.set('client_id', clientId) + url.searchParams.set('response_type', 'code') + url.searchParams.set('redirect_uri', input.redirectUri) + url.searchParams.set('scope', OAUTH_SCOPE) + url.searchParams.set('state', state) + url.searchParams.set('code_challenge', challenge) + url.searchParams.set('code_challenge_method', 'S256') + + const transaction: LoginTransaction = { state, verifier, expiresAt: now + SHOPIFY_LOGIN_TRANSACTION_TTL_SECONDS * 1000 } + return { url: url.toString(), transactionToken: await signToken(input.secret, transaction) } +} + +/** + * Exchange the callback code for a customer session. The state must match the + * signed transaction issued by beginShopifyCustomerLogin, and the customer + * profile is read immediately so the session carries a verified name and + * email without storing anything server-side. + */ +export async function completeShopifyCustomerLogin( + env: ShopifyCustomerEnv, + input: { code: string; state: string; transactionToken: string; redirectUri: string; secret: string }, + options: { fetcher?: typeof fetch; now?: () => number; timeoutMs?: number } = {}, +): Promise<{ session: ShopifyCustomerSession; sessionToken: string } | null> { + const hostname = shopHostname(env.SHOPIFY_SHOP_DOMAIN) + const clientId = env.SHOPIFY_CUSTOMER_CLIENT_ID?.trim() + if (!hostname || !clientId || !input.secret || !input.code || !input.state) return null + const now = options.now?.() ?? Date.now() + + const transaction = await verifyToken(input.secret, input.transactionToken) as LoginTransaction | null + if (!transaction || transaction.state !== input.state || transaction.expiresAt < now) { + logOutcome('transaction_rejected', { present: transaction !== null }) + return null + } + + const fetcher = options.fetcher ?? fetch + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + const discovery = await discover(hostname, fetcher, now, timeoutMs) + if (!discovery) return null + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + code: input.code, + redirect_uri: input.redirectUri, + code_verifier: transaction.verifier, + }) + const grant = await fetchJson(fetcher, discovery.tokenEndpoint, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }, timeoutMs) as Record | null + const accessToken = grant?.['access_token'] + const expiresIn = grant?.['expires_in'] + if (typeof accessToken !== 'string' || !accessToken) { + logOutcome('token_exchange_failed') + return null + } + + const context = await shopifyCustomerContext(env, accessToken, { orders: 0, fetcher, timeoutMs }) + if (context.status !== 'ok') return null + + const lifetimeSeconds = Math.min( + typeof expiresIn === 'number' && expiresIn > 0 ? expiresIn : SHOPIFY_CUSTOMER_SESSION_MAX_SECONDS, + SHOPIFY_CUSTOMER_SESSION_MAX_SECONDS, + ) + const session: ShopifyCustomerSession = { + name: context.customer.name, + email: context.customer.email ?? '', + accessToken, + expiresAt: now + lifetimeSeconds * 1000, + } + if (!session.email) { + // The support flows key customers by email; a session without one cannot + // participate in the verified rail. + logOutcome('missing_email') + return null + } + return { session, sessionToken: await signToken(input.secret, session) } +} + +/** Sign a customer session into the transportable cookie token form. */ +export function signShopifyCustomerSession(secret: string, session: ShopifyCustomerSession): Promise { + return signToken(secret, session) +} + +export async function verifyShopifyCustomerSession( + secret: string, + token: string | null | undefined, + now: number = Date.now(), +): Promise { + if (!secret || !token) return null + const parsed = await verifyToken(secret, token) as ShopifyCustomerSession | null + if (!parsed || typeof parsed.email !== 'string' || typeof parsed.accessToken !== 'string') return null + if (typeof parsed.expiresAt !== 'number' || parsed.expiresAt < now) return null + return { + name: typeof parsed.name === 'string' ? parsed.name : '', + email: parsed.email, + accessToken: parsed.accessToken, + expiresAt: parsed.expiresAt, + } +} + +// Validated against the Customer Account API 2026-07 schema. +const CUSTOMER_CONTEXT_QUERY = `query AbleCustomerContext($first: Int!) { + customer { + displayName + emailAddress { emailAddress } + orders(first: $first, sortKey: PROCESSED_AT, reverse: true) { + nodes { + name + processedAt + financialStatus + fulfillmentStatus + totalPrice { amount currencyCode } + lineItems(first: ${LINE_ITEM_LIMIT}) { nodes { name quantity } } + fulfillments(first: ${TRACKING_LIMIT}) { nodes { trackingInformation { number url } } } + } + } + } +}` + +/** + * Bounded read of the signed-in customer's profile and most recent orders. + * `orders: 0` skips the order projection for a profile-only read. + */ +export async function shopifyCustomerContext( + env: ShopifyCustomerEnv, + accessToken: string, + options: { orders?: number; fetcher?: typeof fetch; now?: () => number; timeoutMs?: number } = {}, +): Promise { + const hostname = shopHostname(env.SHOPIFY_SHOP_DOMAIN) + if (!hostname || !accessToken) return { status: 'unavailable' } + const fetcher = options.fetcher ?? fetch + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + const discovery = await discover(hostname, fetcher, options.now?.() ?? Date.now(), timeoutMs) + if (!discovery) return { status: 'unavailable' } + + const first = Math.max(0, Math.min(options.orders ?? ORDER_LIMIT, ORDER_LIMIT)) + const result = await fetchJson(fetcher, discovery.graphqlEndpoint, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: accessToken }, + body: JSON.stringify({ query: CUSTOMER_CONTEXT_QUERY, variables: { first: Math.max(first, 1) } }), + }, timeoutMs) as Record | null + const data = (result?.['data'] as Record | undefined)?.['customer'] as Record | undefined + if (!data) { + logOutcome('context_unavailable', { hasErrors: Array.isArray(result?.['errors']) }) + return { status: 'unavailable' } + } + + const email = ((data['emailAddress'] as Record | null)?.['emailAddress'] ?? null) as string | null + const rawOrders = ((data['orders'] as Record | null)?.['nodes'] ?? []) as Record[] + const orders: ShopifyCustomerOrder[] = (first === 0 ? [] : rawOrders).slice(0, ORDER_LIMIT).map((order) => ({ + name: String(order['name'] ?? ''), + processedAt: String(order['processedAt'] ?? ''), + financialStatus: typeof order['financialStatus'] === 'string' ? order['financialStatus'] : null, + fulfillmentStatus: typeof order['fulfillmentStatus'] === 'string' ? order['fulfillmentStatus'] : null, + total: ((): { amount: string; currencyCode: string } | null => { + const total = order['totalPrice'] as Record | null + return total && typeof total['amount'] === 'string' && typeof total['currencyCode'] === 'string' + ? { amount: total['amount'], currencyCode: total['currencyCode'] } + : null + })(), + lineItems: (((order['lineItems'] as Record | null)?.['nodes'] ?? []) as Record[]) + .slice(0, LINE_ITEM_LIMIT) + .map((item) => ({ title: String(item['name'] ?? ''), quantity: Number(item['quantity'] ?? 0) })), + tracking: (((order['fulfillments'] as Record | null)?.['nodes'] ?? []) as Record[]) + .flatMap((fulfillment) => { + const tracking = fulfillment['trackingInformation'] + if (Array.isArray(tracking)) return tracking as Record[] + return tracking && typeof tracking === 'object' ? [tracking as Record] : [] + }) + .slice(0, TRACKING_LIMIT) + .map((entry) => ({ + number: typeof entry['number'] === 'string' ? entry['number'] : null, + url: typeof entry['url'] === 'string' ? entry['url'] : null, + })), + })) + return { + status: 'ok', + customer: { name: String(data['displayName'] ?? '').trim() || 'Store customer', email, orders }, + } +} diff --git a/apps/desk/src/voice/conversation.ts b/apps/desk/src/voice/conversation.ts index fe27557..b1daf5e 100644 --- a/apps/desk/src/voice/conversation.ts +++ b/apps/desk/src/voice/conversation.ts @@ -68,13 +68,20 @@ export function prepareVoiceModelMessages(messages: VoiceModelMessage[]): VoiceM export function voiceAgentSystemPrompt( workspaceName: string, - options: { orders?: boolean; contact?: boolean } = {}, + options: { orders?: boolean; contact?: boolean; signedIn?: boolean } = {}, ): string { const name = workspaceName.trim() || 'this workspace' const contact = options.contact !== false const ordersAvailable = options.orders === true + const signedIn = options.signedIn === true + // A signed-in caller must never be told to go find an order number: the + // sentence below REPLACES the ask-for-number instruction because the model + // follows whichever directive it saw last when both are present. + const orderAskLine = signedIn + ? 'The caller is signed in with their store account, so their identity and email are already verified. For an order question without an order number, call list_my_orders first and confirm which order the caller means — never ask them to find the number. When a specific order number is given, call get_order_status with it. The order tools return only the signed-in caller’s own data.' + : 'For order questions, ask the caller for the order number from their order confirmation email.' const orderCapability = ordersAvailable && contact - ? `\nYou can look up the caller's order. For order questions, ask the caller for the order number from their order confirmation email. If any caller message already includes an order number, reuse it and call get_order_status — never ask for it twice. Never ask the caller for an email address; the server already holds this session's email and matches the order automatically. If the tool returns not_found, say you could not find that order for the email on this session: suggest double-checking the number, or restarting the chat with the email used at checkout. If the tool returns unavailable, say you are having trouble checking orders right now — never say the order could not be found. In both cases the reply must end by offering to open a support ticket for the team; never omit that offer. Answer order questions only from tool data — never invent order details, and never speculate about whether an order number exists for a different email. When a product question has no documented answer, offer to check the caller's order so a ticket for the team carries the exact product and purchase date. If the caller's latest message only confirms that they shared their name and email after that undocumented-product handoff, reuse an order number from any earlier caller message and call get_order_status; if none exists, ask only for the order number. Do not create a ticket or claim an order check is underway before the lookup.` + ? `\nYou can look up the caller's order. ${orderAskLine} If any caller message already includes an order number, reuse it and call get_order_status — never ask for it twice. Never ask the caller for an email address; the server already holds this session's email and matches the order automatically. If the tool returns not_found, say you could not find that order for the email on this session: suggest double-checking the number, or restarting the chat with the email used at checkout. If the tool returns unavailable, say you are having trouble checking orders right now — never say the order could not be found. In both cases the reply must end by offering to open a support ticket for the team; never omit that offer. Answer order questions only from tool data — never invent order details, and never speculate about whether an order number exists for a different email. When a product question has no documented answer, offer to check the caller's order so a ticket for the team carries the exact product and purchase date. If the caller's latest message only confirms that they shared their name and email after that undocumented-product handoff, reuse an order number from any earlier caller message and call get_order_status; if none exists, ask only for the order number. Do not create a ticket or claim an order check is underway before the lookup.` : ordersAvailable ? '' : `\nOrder lookup is not available in this workspace. Never claim that you can check an order, shipping status, product purchase, or purchase date. For order questions, say you cannot check orders here and offer to open a support ticket for team follow-up.` diff --git a/apps/desk/src/voice/demo-agent.ts b/apps/desk/src/voice/demo-agent.ts index 015aace..f2b9f8b 100644 --- a/apps/desk/src/voice/demo-agent.ts +++ b/apps/desk/src/voice/demo-agent.ts @@ -51,16 +51,38 @@ import { type VoiceVerificationChallenge, } from './verification' import { dedupAssistantText } from './dedup' +import { + SHOPIFY_CUSTOMER_SESSION_COOKIE, + shopifyCustomerConfigured, + shopifyCustomerContext, + verifyShopifyCustomerSession, + type ShopifyCustomerSession, +} from '../identity/shopify-customer' const VoiceAgent = withVoice(Agent, { historyLimit: 16, maxMessageCount: 80 }) const LOCAL_SECRET = 'able-local-capability-secret-not-for-production' +function readShopifyCustomerCookie(request: Request): string | null { + const header = request.headers.get('Cookie') ?? '' + for (const part of header.split(';')) { + const [key, ...rest] = part.trim().split('=') + if (key === SHOPIFY_CUSTOMER_SESSION_COOKIE) return rest.join('=') || null + } + return null +} + type VoiceConnectionState = { clientIp?: string hostname?: string origin?: string /** True after this connection passed the Turnstile session check. */ sessionProofPassed?: boolean + /** + * The raw signed Shopify customer-session token presented on the WebSocket + * upgrade. Verified lazily per use; the cookie re-arrives on every + * reconnect, so verified identity survives connection drops by transport. + */ + shopifyCustomerToken?: string | null } /** @@ -127,9 +149,17 @@ export class AbleDeskAgent extends VoiceAgent { clientIp: context.request.headers.get('CF-Connecting-IP') ?? 'unknown', hostname: url.hostname, origin: url.origin, + shopifyCustomerToken: readShopifyCustomerCookie(context.request), } satisfies VoiceConnectionState) } + /** The verified store-account identity on this connection, if any. */ + async #shopifyCustomer(connection: Connection): Promise { + const state = connectionState(connection) + if (!state.shopifyCustomerToken || !shopifyCustomerConfigured(this.env)) return null + return verifyShopifyCustomerSession(this.#secret(state), state.shopifyCustomerToken) + } + beforeCallStart(connection: Connection): boolean { if (!voiceDemoEnabled(this.env)) return false if (connectionState(connection).sessionProofPassed !== true) { @@ -247,7 +277,15 @@ export class AbleDeskAgent extends VoiceAgent { } const session = await this.#session() - const contact = hasVoiceContact(session) ? session.contact : null + const shopifyCustomer = await this.#shopifyCustomer(context.connection) + // A store-account sign-in IS contact on file — verified, with no card + // interruption. An explicitly shared contact still takes precedence so a + // caller can direct follow-up to a different address. + const contact = hasVoiceContact(session) + ? session.contact + : shopifyCustomer + ? { name: shopifyCustomer.name, email: shopifyCustomer.email } + : null const turnRequestId = `voice-turn-${crypto.randomUUID()}` const messages = context.messages.map(({ role, content }) => ({ role, content })) const classifiedCategory = classifyEscalation(transcript) @@ -327,6 +365,7 @@ export class AbleDeskAgent extends VoiceAgent { system: voiceAgentSystemPrompt(workspaceShortName(settings.displayName), { orders: ordersAvailable, contact: contact !== null, + signedIn: shopifyCustomer !== null, }), messages: prepareVoiceModelMessages(messages), tools: { @@ -355,6 +394,16 @@ export class AbleDeskAgent extends VoiceAgent { } }, }), + ...(shopifyCustomer ? { + list_my_orders: tool({ + 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.", + inputSchema: z.object({}), + execute: async () => { + const result = await shopifyCustomerContext(this.env, shopifyCustomer.accessToken, {}) + return result.status === 'ok' ? { status: 'ok', orders: result.customer.orders } : { status: 'unavailable' } + }, + }), + } : {}), ...(ordersEnabled ? { get_order_status: tool({ 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.", diff --git a/apps/desk/src/voice/demo-page.ts b/apps/desk/src/voice/demo-page.ts index c38cf49..e73c4f5 100644 --- a/apps/desk/src/voice/demo-page.ts +++ b/apps/desk/src/voice/demo-page.ts @@ -67,11 +67,19 @@ function monogram(displayName: string): string { return (letters || 'S').toUpperCase() } +export type VoiceIdentityView = { + /** True when Shopify customer sign-in is configured for this deployment. */ + configured: boolean + /** The signed-in customer's display name, or null when anonymous. */ + customerName: string | null +} + export function voiceDemoPageResponse( branding: VoiceBranding, turnstileSiteKey = '', ordersEnabled = false, topics: VoiceHelpTopic[] = [], + identity: VoiceIdentityView = { configured: false, customerName: null }, ): Response { const title = /\bsupport$/i.test(branding.displayName.trim()) ? `${branding.displayName} — assistant` @@ -126,7 +134,11 @@ export function voiceDemoPageResponse(
diff --git a/apps/desk/src/worker.tsx b/apps/desk/src/worker.tsx index a0fab48..a0a0c6f 100644 --- a/apps/desk/src/worker.tsx +++ b/apps/desk/src/worker.tsx @@ -33,6 +33,15 @@ import { verifyPublicWrite } from './security/public-write' import { isLocalUrl, requestSurface } from './security/operator-host' import { loadWorkspaceSettings, updateWorkspaceSettings, type WorkspaceSettings } from './settings' import { shopifyConfigured } from './integrations/shopify' +import { + beginShopifyCustomerLogin, + completeShopifyCustomerLogin, + SHOPIFY_CUSTOMER_LOGIN_COOKIE, + SHOPIFY_CUSTOMER_SESSION_COOKIE, + SHOPIFY_LOGIN_TRANSACTION_TTL_SECONDS, + shopifyCustomerConfigured, + verifyShopifyCustomerSession, +} from './identity/shopify-customer' import { acceptWhatsAppWebhook, verifyWhatsAppWebhook } from './whatsapp/webhook' import { createCustomerWorkspace } from './suite/customer-workspace' import { createConversationRouter } from './suite/conversation-routing' @@ -138,6 +147,71 @@ function portalCustomization(settings: WorkspaceSettings): PortalCustomization { } } +function readCookieValue(request: Request, name: string): string | null { + const header = request.headers.get('Cookie') ?? '' + for (const part of header.split(';')) { + const [key, ...rest] = part.trim().split('=') + if (key === name) return rest.join('=') || null + } + return null +} + +function authCookie(name: string, value: string, maxAgeSeconds: number): string { + return `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; HttpOnly; Secure; SameSite=Lax` +} + +function authRedirect(location: string, cookies: string[]): Response { + const headers = new Headers({ location, 'cache-control': 'no-store' }) + for (const cookie of cookies) headers.append('set-cookie', cookie) + return new Response(null, { status: 302, headers }) +} + +/** + * The optional verified-identity rail: sign in with the deployment's Shopify + * customer account. Every failure path lands the visitor back on the portal + * as anonymous — sign-in never blocks the progressive contact flow. + */ +async function shopifyAuthResponse(request: Request, env: Env): Promise { + const url = new URL(request.url) + if (request.method !== 'GET') return notFoundResponse() + if (url.pathname === '/auth/shopify/logout') { + return authRedirect('/', [ + authCookie(SHOPIFY_CUSTOMER_SESSION_COOKIE, '', 0), + authCookie(SHOPIFY_CUSTOMER_LOGIN_COOKIE, '', 0), + ]) + } + const secret = capabilitySecret(request, env) + if (!shopifyCustomerConfigured(env) || !secret) return notFoundResponse() + const redirectUri = new URL('/auth/shopify/callback', url.origin).toString() + + if (url.pathname === '/auth/shopify/start') { + const started = await beginShopifyCustomerLogin(env, { redirectUri, secret }) + if (!started) return authRedirect('/', [authCookie(SHOPIFY_CUSTOMER_LOGIN_COOKIE, '', 0)]) + return authRedirect(started.url, [ + authCookie(SHOPIFY_CUSTOMER_LOGIN_COOKIE, started.transactionToken, SHOPIFY_LOGIN_TRANSACTION_TTL_SECONDS), + ]) + } + + if (url.pathname === '/auth/shopify/callback') { + const code = url.searchParams.get('code') ?? '' + const state = url.searchParams.get('state') ?? '' + const transactionToken = readCookieValue(request, SHOPIFY_CUSTOMER_LOGIN_COOKIE) ?? '' + const completed = code && state && transactionToken + ? await completeShopifyCustomerLogin(env, { code, state, transactionToken, redirectUri, secret }) + : null + if (!completed) { + return authRedirect('/', [authCookie(SHOPIFY_CUSTOMER_LOGIN_COOKIE, '', 0)]) + } + const maxAge = Math.max(60, Math.floor((completed.session.expiresAt - Date.now()) / 1000)) + return authRedirect('/', [ + authCookie(SHOPIFY_CUSTOMER_SESSION_COOKIE, completed.sessionToken, maxAge), + authCookie(SHOPIFY_CUSTOMER_LOGIN_COOKIE, '', 0), + ]) + } + + return notFoundResponse() +} + function rewritePath(request: Request, prefix: string): Request { const url = new URL(request.url) url.pathname = url.pathname.slice(prefix.length) || '/' @@ -159,6 +233,10 @@ async function portalResponse(request: Request, env: Env, ctx: ExecutionContext) const knowledge = createPublicKnowledge(env.DB) if (request.method === 'GET' && url.pathname === '/' && voiceReady) { const content = await knowledge.home() + const customerSession = await verifyShopifyCustomerSession( + capabilitySecret(request, env), + readCookieValue(request, SHOPIFY_CUSTOMER_SESSION_COOKIE), + ) return voiceDemoPageResponse( voiceBranding(settings), env.TURNSTILE_SITE_KEY, @@ -172,6 +250,10 @@ async function portalResponse(request: Request, env: Env, ctx: ExecutionContext) .slice(0, 3) .map((article) => ({ slug: article.slug, title: article.title })), })), + { + configured: shopifyCustomerConfigured(env), + customerName: customerSession?.name ?? null, + }, ) } const helpdesk = createHelpdesk({ @@ -354,6 +436,7 @@ async function fetchHandler(request: Request, env: Env, ctx: ExecutionContext): } if (url.pathname === '/mcp') return mcpResponse(request, env) if (url.pathname === '/ops' || url.pathname.startsWith('/ops/')) return opsResponse(request, env, ctx) + if (url.pathname.startsWith('/auth/shopify/')) return shopifyAuthResponse(request, env) return portalResponse(request, env, ctx) } catch (error) { console.error(JSON.stringify({ event: 'request_failed', error: error instanceof Error ? error.name : 'UnknownError' })) diff --git a/apps/desk/test/shopify-auth-routes.test.ts b/apps/desk/test/shopify-auth-routes.test.ts new file mode 100644 index 0000000..35a62da --- /dev/null +++ b/apps/desk/test/shopify-auth-routes.test.ts @@ -0,0 +1,39 @@ +import { SELF } from 'cloudflare:test' +import { describe, expect, it } from 'vitest' + +import { SHOPIFY_CUSTOMER_LOGIN_COOKIE, SHOPIFY_CUSTOMER_SESSION_COOKIE } from '../src/identity/shopify-customer' + +function setCookies(response: Response): string[] { + return response.headers.getSetCookie?.() ?? [] +} + +describe('shopify customer sign-in routes', () => { + it('clears both cookies on logout and lands on the portal', async () => { + const response = await SELF.fetch('http://localhost/auth/shopify/logout', { redirect: 'manual' }) + expect(response.status).toBe(302) + expect(response.headers.get('location')).toBe('/') + const cookies = setCookies(response) + expect(cookies.some((cookie) => cookie.startsWith(`${SHOPIFY_CUSTOMER_SESSION_COOKIE}=;`) && cookie.includes('Max-Age=0'))).toBe(true) + expect(cookies.some((cookie) => cookie.startsWith(`${SHOPIFY_CUSTOMER_LOGIN_COOKIE}=;`) && cookie.includes('Max-Age=0'))).toBe(true) + }) + + it('lands a callback without a login transaction back on the portal as anonymous', async () => { + const response = await SELF.fetch('http://localhost/auth/shopify/callback?code=x&state=y', { redirect: 'manual' }) + expect(response.status).toBe(302) + expect(response.headers.get('location')).toBe('/') + expect(setCookies(response).some((cookie) => cookie.includes(`${SHOPIFY_CUSTOMER_SESSION_COOKIE}=`) && !cookie.includes('Max-Age=0'))).toBe(false) + }) + + it('fails start safely back to the portal when discovery is unreachable', async () => { + // The test runtime cannot reach the example shop domain, so login begin + // returns null; the visitor must land on the portal, not an error page. + const response = await SELF.fetch('http://localhost/auth/shopify/start', { redirect: 'manual' }) + expect(response.status).toBe(302) + expect(response.headers.get('location')).toBe('/') + }) + + it('rejects non-GET methods', async () => { + const response = await SELF.fetch('http://localhost/auth/shopify/start', { method: 'POST', redirect: 'manual' }) + expect(response.status).toBe(404) + }) +}) diff --git a/apps/desk/test/shopify-customer.test.ts b/apps/desk/test/shopify-customer.test.ts new file mode 100644 index 0000000..d2357a0 --- /dev/null +++ b/apps/desk/test/shopify-customer.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + beginShopifyCustomerLogin, + completeShopifyCustomerLogin, + resetShopifyCustomerDiscoveryCache, + shopifyCustomerConfigured, + shopifyCustomerContext, + signShopifyCustomerSession, + verifyShopifyCustomerSession, +} from '../src/identity/shopify-customer' + +const ENV = { SHOPIFY_SHOP_DOMAIN: 'shop.example.test', SHOPIFY_CUSTOMER_CLIENT_ID: 'client-123' } +// Deliberately low-entropy plain words: the public-history secret scan must +// never mistake this test fixture for a real signing key. +const SECRET = 'test-cookie-signing-words-only' +const REDIRECT = 'https://support.example.test/auth/shopify/callback' + +function discoveryFetcher(overrides: Record = {}): ReturnType> { + return vi.fn(async (input) => { + const url = String(input instanceof Request ? input.url : input) + if (url.endsWith('/.well-known/openid-configuration')) { + return Response.json({ + authorization_endpoint: 'https://auth.example.test/oauth/authorize', + token_endpoint: 'https://auth.example.test/oauth/token', + }) + } + if (url.endsWith('/.well-known/customer-account-api')) { + return Response.json({ graphql_api: 'https://api.example.test/customer/graphql' }) + } + if (url === 'https://auth.example.test/oauth/token') { + return Response.json({ access_token: 'shcat-token', expires_in: 1800, ...overrides['token'] as object }) + } + if (url === 'https://api.example.test/customer/graphql') { + return Response.json(overrides['graphql'] ?? { + data: { + customer: { + displayName: 'Rhea Kapoor', + emailAddress: { emailAddress: 'rhea@example.test' }, + orders: { + nodes: [{ + name: '#2026-27/7903', + processedAt: '2026-08-07T07:14:45Z', + financialStatus: 'PAID', + fulfillmentStatus: 'FULFILLED', + totalPrice: { amount: '4997.0', currencyCode: 'INR' }, + lineItems: { nodes: [{ name: 'Grinder', quantity: 1 }] }, + fulfillments: { nodes: [{ trackingInformation: [{ number: 'TRACK9', url: 'https://track.example.test/9' }] }] }, + }], + }, + }, + }, + }) + } + return new Response('not found', { status: 404 }) + }) +} + +beforeEach(() => resetShopifyCustomerDiscoveryCache()) + +describe('shopify customer sign-in configuration', () => { + it('activates only with a shop domain and a customer client id', () => { + expect(shopifyCustomerConfigured(ENV)).toBe(true) + expect(shopifyCustomerConfigured({ SHOPIFY_SHOP_DOMAIN: 'shop.example.test' })).toBe(false) + expect(shopifyCustomerConfigured({ SHOPIFY_CUSTOMER_CLIENT_ID: 'client-123' })).toBe(false) + expect(shopifyCustomerConfigured({})).toBe(false) + }) +}) + +describe('login begin and completion', () => { + it('builds a PKCE authorization redirect whose state matches the signed transaction', async () => { + const fetcher = discoveryFetcher() + const started = await beginShopifyCustomerLogin(ENV, { redirectUri: REDIRECT, secret: SECRET }, { fetcher }) + expect(started).not.toBeNull() + const url = new URL(started!.url) + expect(url.origin + url.pathname).toBe('https://auth.example.test/oauth/authorize') + expect(url.searchParams.get('client_id')).toBe('client-123') + expect(url.searchParams.get('code_challenge_method')).toBe('S256') + expect(url.searchParams.get('redirect_uri')).toBe(REDIRECT) + expect(url.searchParams.get('code_challenge')).toBeTruthy() + expect(url.searchParams.get('state')).toBeTruthy() + + const completed = await completeShopifyCustomerLogin(ENV, { + code: 'auth-code', + state: url.searchParams.get('state')!, + transactionToken: started!.transactionToken, + redirectUri: REDIRECT, + secret: SECRET, + }, { fetcher }) + expect(completed).not.toBeNull() + expect(completed!.session).toMatchObject({ name: 'Rhea Kapoor', email: 'rhea@example.test', accessToken: 'shcat-token' }) + + const verified = await verifyShopifyCustomerSession(SECRET, completed!.sessionToken) + expect(verified).toMatchObject({ email: 'rhea@example.test' }) + }) + + it('rejects a state that does not match the signed transaction', async () => { + const fetcher = discoveryFetcher() + const started = await beginShopifyCustomerLogin(ENV, { redirectUri: REDIRECT, secret: SECRET }, { fetcher }) + const completed = await completeShopifyCustomerLogin(ENV, { + code: 'auth-code', + state: 'forged-state', + transactionToken: started!.transactionToken, + redirectUri: REDIRECT, + secret: SECRET, + }, { fetcher }) + expect(completed).toBeNull() + }) + + it('rejects an expired login transaction', async () => { + const fetcher = discoveryFetcher() + const started = await beginShopifyCustomerLogin(ENV, { redirectUri: REDIRECT, secret: SECRET }, { fetcher, now: () => 1_000 }) + const state = new URL(started!.url).searchParams.get('state')! + const completed = await completeShopifyCustomerLogin(ENV, { + code: 'auth-code', + state, + transactionToken: started!.transactionToken, + redirectUri: REDIRECT, + secret: SECRET, + }, { fetcher, now: () => 1_000 + 11 * 60_000 }) + expect(completed).toBeNull() + }) + + it('refuses a session when the store returns no customer email', async () => { + const fetcher = discoveryFetcher({ + graphql: { data: { customer: { displayName: 'No Email', emailAddress: null, orders: { nodes: [] } } } }, + }) + const started = await beginShopifyCustomerLogin(ENV, { redirectUri: REDIRECT, secret: SECRET }, { fetcher }) + const state = new URL(started!.url).searchParams.get('state')! + const completed = await completeShopifyCustomerLogin(ENV, { + code: 'auth-code', + state, + transactionToken: started!.transactionToken, + redirectUri: REDIRECT, + secret: SECRET, + }, { fetcher }) + expect(completed).toBeNull() + }) +}) + +describe('session token boundary', () => { + const session = { name: 'Rhea', email: 'rhea@example.test', accessToken: 'shcat-token', expiresAt: Date.now() + 60_000 } + + it('round-trips a signed session and rejects tampering and expiry', async () => { + const token = await signShopifyCustomerSession(SECRET, session) + expect(await verifyShopifyCustomerSession(SECRET, token)).toMatchObject({ email: 'rhea@example.test' }) + expect(await verifyShopifyCustomerSession('other-secret', token)).toBeNull() + expect(await verifyShopifyCustomerSession(SECRET, `${token}x`)).toBeNull() + expect(await verifyShopifyCustomerSession(SECRET, token, session.expiresAt + 1)).toBeNull() + expect(await verifyShopifyCustomerSession(SECRET, null)).toBeNull() + }) +}) + +describe('bounded customer context', () => { + it('projects profile, orders, line items, and tracking without extra fields', async () => { + const result = await shopifyCustomerContext(ENV, 'shcat-token', { fetcher: discoveryFetcher() }) + expect(result.status).toBe('ok') + if (result.status !== 'ok') return + expect(result.customer).toMatchObject({ name: 'Rhea Kapoor', email: 'rhea@example.test' }) + expect(result.customer.orders).toHaveLength(1) + expect(result.customer.orders[0]).toMatchObject({ + name: '#2026-27/7903', + financialStatus: 'PAID', + fulfillmentStatus: 'FULFILLED', + total: { amount: '4997.0', currencyCode: 'INR' }, + lineItems: [{ title: 'Grinder', quantity: 1 }], + tracking: [{ number: 'TRACK9', url: 'https://track.example.test/9' }], + }) + }) + + it('turns provider errors into a typed unavailable result', async () => { + const failing = vi.fn(async (input) => { + const url = String(input instanceof Request ? input.url : input) + if (url.includes('.well-known')) return discoveryFetcher()(input as never) + return new Response('boom', { status: 500 }) + }) + expect(await shopifyCustomerContext(ENV, 'shcat-token', { fetcher: failing })).toEqual({ status: 'unavailable' }) + expect(await shopifyCustomerContext({}, 'shcat-token', { fetcher: discoveryFetcher() })).toEqual({ status: 'unavailable' }) + }) +}) diff --git a/apps/desk/test/voice-agent.test.ts b/apps/desk/test/voice-agent.test.ts index 6764405..c44fa81 100644 --- a/apps/desk/test/voice-agent.test.ts +++ b/apps/desk/test/voice-agent.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { HUMAN_HELP_MESSAGE } from '../src/voice/escalation' import { ORDER_LOOKUP_CONTACT_CONTINUATION } from '../src/voice/contact' +import { SHOPIFY_CUSTOMER_SESSION_COOKIE, signShopifyCustomerSession } from '../src/identity/shopify-customer' type TestAgentEnv = typeof env & { AbleDeskAgent: DurableObjectNamespace } @@ -21,7 +22,7 @@ function nextMessage(socket: WebSocket, predicate: (value: Record { +async function connectAgent(name: string, origin = 'http://localhost', cookie?: string): Promise { const namespace = (env as TestAgentEnv).AbleDeskAgent const stub = namespace.get(namespace.idFromName(name)) // Every real connection carries a client IP and the public rate limits key @@ -29,7 +30,11 @@ async function connectAgent(name: string, origin = 'http://localhost'): Promise< // rate-limit budget as the suite grows. const bytes = crypto.getRandomValues(new Uint8Array(3)) const response = await stub.fetch(new Request(`${origin}/agents/able-desk-agent/${name}`, { - headers: { upgrade: 'websocket', 'CF-Connecting-IP': `10.${bytes[0]}.${bytes[1]}.${bytes[2]}` }, + headers: { + upgrade: 'websocket', + 'CF-Connecting-IP': `10.${bytes[0]}.${bytes[1]}.${bytes[2]}`, + ...(cookie ? { Cookie: cookie } : {}), + }, })) expect(response.status).toBe(101) const socket = response.webSocket @@ -226,6 +231,61 @@ describe('voice agent WebSocket boundary', () => { } }) + it('treats a store-account session as verified contact and never shows the card', async () => { + const token = await signShopifyCustomerSession('able-local-capability-secret-not-for-production', { + name: 'Signed In Customer', + email: 'signed-in@example.test', + accessToken: 'shcat-test-token', + expiresAt: Date.now() + 60_000, + }) + const socket = await connectAgent( + `shopify-session-${crypto.randomUUID()}`, + 'http://localhost', + `${SHOPIFY_CUSTOMER_SESSION_COOKIE}=${token}`, + ) + try { + await proveSession(socket) + let cardShown = false + socket.addEventListener('message', (event) => { + if (typeof event.data === 'string' && JSON.parse(event.data).type === 'voice_contact_required') cardShown = true + }) + // A bare order number from a signed-in caller goes straight to lookup + // under the verified email — no contact card, no guardrail. + const reply = nextMessage(socket, (message) => message.type === 'transcript_end') + socket.send(JSON.stringify({ type: 'text_message', text: '#2026-27/7903' })) + const message = await reply + expect(String(message.text)).toContain('trouble checking orders') + expect(String(message.text)).not.toMatch(/card below|only help with support/i) + expect(cardShown).toBe(false) + } finally { + socket.close() + } + }) + + it('ignores a tampered store-account session token', async () => { + const token = await signShopifyCustomerSession('able-local-capability-secret-not-for-production', { + name: 'Tampered Customer', + email: 'tampered@example.test', + accessToken: 'shcat-test-token', + expiresAt: Date.now() + 60_000, + }) + const socket = await connectAgent( + `shopify-tampered-${crypto.randomUUID()}`, + 'http://localhost', + `${SHOPIFY_CUSTOMER_SESSION_COOKIE}=${token}TAMPERED`, + ) + try { + await proveSession(socket) + // Without a valid session the bare number behaves anonymously: the + // contact card is requested once. + const cardRequested = nextMessage(socket, (message) => message.type === 'voice_contact_required') + socket.send(JSON.stringify({ type: 'text_message', text: '#2026-27/7903' })) + await expect(cardRequested).resolves.toMatchObject({ reason: 'order_lookup' }) + } finally { + socket.close() + } + }) + it('asks for contact once when a bare order number arrives with nobody on file', async () => { const socket = await connectAgent(`bare-number-${crypto.randomUUID()}`) try { diff --git a/apps/desk/vitest.config.ts b/apps/desk/vitest.config.ts index 9e3eae2..aeef576 100644 --- a/apps/desk/vitest.config.ts +++ b/apps/desk/vitest.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ ABLE_OPERATOR_HOSTNAME: 'operators.example.test', SHOPIFY_SHOP_DOMAIN: 'shop.example.test', SHOPIFY_ADMIN_TOKEN: 'local-test-token', + SHOPIFY_CUSTOMER_CLIENT_ID: 'customer-test-client', VOICE_TEST_OTP_CODE: '123456', WHATSAPP_VERIFY_TOKEN: 'whatsapp-test-verify-token', WHATSAPP_APP_SECRET: 'whatsapp-test-app-secret', diff --git a/apps/desk/wrangler.jsonc b/apps/desk/wrangler.jsonc index 537303e..a4338db 100644 --- a/apps/desk/wrangler.jsonc +++ b/apps/desk/wrangler.jsonc @@ -9,7 +9,7 @@ "assets": { "binding": "ASSETS", "directory": "./public", - "run_worker_first": ["/", "/healthz", "/mcp", "/ops", "/ops/*", "/webhooks/whatsapp", "/kb*", "/requests*", "/workspace-theme.css", "/portal/*", "/voice", "/demo/voice", "/agents/*"] + "run_worker_first": ["/", "/healthz", "/mcp", "/ops", "/ops/*", "/webhooks/whatsapp", "/kb*", "/requests*", "/workspace-theme.css", "/portal/*", "/voice", "/demo/voice", "/agents/*", "/auth/*"] }, "vars": { "ABLE_VOICE_DEMO_ENABLED": "1" diff --git a/docs/deployment.md b/docs/deployment.md index 23040e0..8feb8b7 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -98,6 +98,25 @@ Provider acceptance records the setup test as accepted. It does not prove inbox Create a Turnstile widget for the portal hostname and expose only its site key through private workspace/deployment configuration. Keep the secret in Wrangler. The Worker also applies a rate-limit binding; production deployments should add appropriate WAF rules and bot controls for their risk profile. +## 5a. Optional: store-account sign-in for order help + +When the deployment's Shopify store uses new customer accounts, the support +portal can offer "Sign in for order help": a signed-in customer skips the +contact card and may ask about their own recent orders without an order +number. Anonymous support is unaffected — sign-in is an additive rail. + +1. Create a Customer Account API public client for the store (for example + through the Headless sales channel) and record its client ID. +2. Add `https:///auth/shopify/callback` to the client's + allowed redirect URIs. +3. Set the secret: `npx wrangler secret put SHOPIFY_CUSTOMER_CLIENT_ID`. + +The sign-in link appears on the assistant homepage automatically once the +client ID and shop domain are configured. Sessions are short-lived, carried in +a signed secure cookie, and read a bounded projection only: profile plus +recent orders with status and tracking. Sign-in failures always land the +visitor back on the portal as anonymous. + ## 6. Connect WhatsApp Cloud API Use the Meta app owned by this single-tenant deployment. In **WhatsApp > Configuration**, set the callback URL to the public portal origin plus `/webhooks/whatsapp`, enter the same private value stored in `WHATSAPP_VERIFY_TOKEN`, verify the callback, and subscribe the app to the WABA's `messages` webhook field.