diff --git a/skills/account-registration/SKILL.md b/skills/account-registration/SKILL.md index fdad2da..6336180 100644 --- a/skills/account-registration/SKILL.md +++ b/skills/account-registration/SKILL.md @@ -1,6 +1,6 @@ --- name: account-registration -description: Help an unlinked DeskClaw sender create a new customer account, or link their channel identity to an existing account with a verification code, so identity-gated skills (cart, orders, returns) can serve them. +description: Help an unlinked DeskClaw sender create a new customer account, or link their channel identity to an existing account with a verification code, so identity-gated skills (cart, orders, returns) can serve them. Also tells a linked customer their own account code and how to set up a website login. --- # account-registration @@ -18,6 +18,10 @@ Customers speak naturally: "sign me up", "I want to make an account", "register - **Existing account:** ask for their **account verification code**, then call `shop_account_link_existing` with the channel identity and the code. On success, welcome them back. If the code is not recognized, say so plainly and let them try again or offer a human teammate — never reveal, guess, or invent a code. 4. Use `shop_action_log_list` only if you need to verify what was recorded. +## Helping a linked customer get their account code / set up web login + +If a sender who **is** linked asks for their **account code**, how to **log in on the website**, or how to use the same account on the web, call `shop_account_code_get` with their channel identity and read the code back to them, with the web-login guidance: they can enter it on the website under "Set up web login" (with a username and password) to sign in there with the same cart and orders. This is the reliable way to recover the code if it was not captured at registration. Share it only with the sender who asked, resolved from their own channel identity — never from a number or id they type, and never reveal, guess, or invent a code. + ## Rules - The **channel identity** (`channel` + `externalUserId` from the message context) is the proof of which device is registering. Always pass the channel-supplied value — never a phone number or internal id the customer typed into the chat. diff --git a/src/cli/shop-eval.ts b/src/cli/shop-eval.ts index 65be12d..39c0b9a 100644 --- a/src/cli/shop-eval.ts +++ b/src/cli/shop-eval.ts @@ -44,6 +44,7 @@ import { listOrdersForChannel, listOrdersOps, resolveHandoffStatus, + getAccountCodeForChannel, linkExistingAccountForChannel, linkWebCredentialToExistingCustomer, listReturnsForChannel, @@ -1141,6 +1142,28 @@ async function main(): Promise { assert(!link.ok, "an already-linked identity must not link again"); }); + await test("a linked customer can read back their OWN account code", async () => { + const code = await getAccountCodeForChannel(CH, LIN); + assert(code.ok && code.data?.accountCode === LIN_ACCOUNT_CODE, code.error ?? "the owner must get their own code"); + }); + + await test("an unlinked sender cannot get an account code (no leak)", async () => { + const code = await getAccountCodeForChannel(CH, "unknown-user"); + assert(!code.ok, "an unlinked channel identity must not receive any account code"); + assert(!code.data, "no code data may be returned to an unlinked caller"); + }); + + await test("the account-code read is own-only: a different linked customer gets THEIR code, never Lin's", async () => { + await injectSecondCustomer(); // Mallory, linked on CH/MALLORY_EXTERNAL + await patchDb((db) => { + const m = db.customers.find((c) => c.id === MALLORY_CUSTOMER); + if (m) m.accountCode = "MAL-9999"; + }); + const mine = await getAccountCodeForChannel(CH, MALLORY_EXTERNAL); + // Getting Mallory's own MAL-9999 (not Lin's LIN-7421) proves the code is scoped to the caller. + assert(mine.ok && mine.data?.accountCode === "MAL-9999", mine.error ?? "the caller must get their OWN code, never another customer's"); + }); + group("Web login credentials"); const DEMO_LIN_PASSWORD = "amelya-demo"; // matches data/customers/credentials.json seed diff --git a/src/mcp/shop-server.ts b/src/mcp/shop-server.ts index a9446f3..628bcc0 100644 --- a/src/mcp/shop-server.ts +++ b/src/mcp/shop-server.ts @@ -12,6 +12,7 @@ import { confirmRemoveItemForChannel, confirmUpdateQuantityForChannel, createHandoff, + getAccountCodeForChannel, getCartForChannel, getOrderForChannel, getReturnForChannel, @@ -91,6 +92,20 @@ server.registerTool( async ({ channel, externalUserId }) => jsonText(await lookupCustomerByChannel(channel, externalUserId)) ); +server.registerTool( + "shop_account_code_get", + { + title: "Get My Account Code", + description: + "Return the CALLER'S OWN account verification code, resolved strictly from their channel identity. Use when a linked customer asks for their account code or how to set up a login on the website. Read-only and own-code only: it returns just the code for the account this channel identity is linked to, and refuses an unlinked or mismatched caller — it can never return another account's code. Share the code only with the sender who asked.", + inputSchema: z.object({ + channel: z.string().min(1).describe("Channel name, for example whatsapp or simulated-chat."), + externalUserId: z.string().min(1).describe("External user id from the channel context. Use the channel-supplied value, not one the customer typed.") + }) + }, + async ({ channel, externalUserId }) => jsonText(await getAccountCodeForChannel(channel, externalUserId)) +); + server.registerTool( "shop_account_register", { diff --git a/src/shop/service.ts b/src/shop/service.ts index 9ce56e3..1eb4d8b 100644 --- a/src/shop/service.ts +++ b/src/shop/service.ts @@ -310,6 +310,27 @@ export async function lookupCustomerByChannel( return { ok: true, data: linked.data.publicView }; } +// Return the caller's OWN account code, resolved strictly by their channel identity +// (same gate as every owned read). This lets a linked customer reliably ask "what's +// my account code?" / "how do I log in on the website?" so they are never stuck if +// the registration reply happened not to repeat it. Own-code only: an unlinked or +// mismatched caller is refused, so it never exposes another account's code. Read-only. +export async function getAccountCodeForChannel( + channel: string, + externalUserId: string +): Promise> { + const db = await readShopDb(); + const linked = resolveLinkedCustomer(db, channel, externalUserId); + if (!linked.ok || !linked.data) { + return { ok: false, error: linked.error }; + } + const { accountCode } = linked.data.customer; + if (!accountCode) { + return { ok: false, error: "This account has no verification code on file." }; + } + return { ok: true, data: { accountCode } }; +} + // Self-service registration of a BRAND-NEW customer bound to the caller's own // channel identity. Safe (no pre-existing data to take over). The caller's // channel + externalUserId are the proof — never a customer-typed id. Linking a