Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion skills/account-registration/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions src/cli/shop-eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
listOrdersForChannel,
listOrdersOps,
resolveHandoffStatus,
getAccountCodeForChannel,
linkExistingAccountForChannel,
linkWebCredentialToExistingCustomer,
listReturnsForChannel,
Expand Down Expand Up @@ -1141,6 +1142,28 @@ async function main(): Promise<void> {
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
Expand Down
15 changes: 15 additions & 0 deletions src/mcp/shop-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
confirmRemoveItemForChannel,
confirmUpdateQuantityForChannel,
createHandoff,
getAccountCodeForChannel,
getCartForChannel,
getOrderForChannel,
getReturnForChannel,
Expand Down Expand Up @@ -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",
{
Expand Down
21 changes: 21 additions & 0 deletions src/shop/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServiceResult<{ accountCode: string }>> {
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
Expand Down
Loading