From ae3a37fe725eadf3f5ab383507d66b39a8f88d35 Mon Sep 17 00:00:00 2001 From: levii831 <90572290+Lelevinson@users.noreply.github.com> Date: Thu, 11 Jun 2026 07:53:07 +0000 Subject: [PATCH] feat: web login for a chat-registered account (cross-channel bridge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap where a customer who registered over a chat channel (e.g. WhatsApp) had a customer + account-link but no web credential, so they could not sign in to the storefront. New web "Set up web login" flow at /register/link: the customer supplies a username + password + their accountCode, and linkWebCredentialToExistingCustomer verifies the code and attaches a Credential + a web AccountLink to the EXISTING customerId — so the web session resolves to the same customer (same cart/orders). It is the web-side symmetric twin of the chat shop_account_link_existing tool, gated by the same accountCode, and so the same demo-grade security level (not stronger identity proof). Refuses an unrecognized code, an account that already has a web login, and a taken username; writes an account.web_link_existing audit log. shop_account_register now returns the new accountCode, and the account-registration skill reads it back so a chat-registered customer knows what to enter on the web. ARCHITECTURE §5 updated first (promoted from the deferred list, demo-grade caveat). shop:eval +4 (94 total): claim → same customer, wrong code refused, already-has-web refused, taken username refused. Verified live via Puppeteer: WhatsApp-registered "Bridgey" claimed a web login and landed on the same customer (whatsapp + web links, one customer record, hashed password). Co-Authored-By: Claude Opus 4.8 --- ARCHITECTURE.md | 4 +- skills/account-registration/SKILL.md | 2 +- src/cli/shop-eval.ts | 52 ++++++++++++++++ src/shop/service.ts | 86 ++++++++++++++++++++++++++- web/app/register/link/page.tsx | 23 +++++++ web/components/auth/AuthForm.tsx | 89 ++++++++++++++++++++++------ web/lib/auth/actions.ts | 25 +++++++- 7 files changed, 257 insertions(+), 24 deletions(-) create mode 100644 web/app/register/link/page.tsx diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index be07c39..a1a8f0a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -123,6 +123,8 @@ The customer **skills** below were decided in the 2026-05-29 roadmap session and - ~~**Self-service account registration & linking**~~ — **Shipped 2026-06-09** (promoted from the deferred "Self-service account linking" below). An `account-registration` skill + `shop_account_register` / `shop_account_link_existing` tools let an unlinked sender (a) create a **brand-new** customer account bound to their own channel identity — safe, since a fresh account has no pre-existing data to take over — or (b) link an **existing** account, gated by that account's **verification code** (`accountCode` on the customer record — a **demo-grade stand-in for an OTP delivered to the contact on file**). Both bind to the **channel-supplied** identity, never a customer-typed id, and both write an audit log (`account.register` / `account.link_existing`). No new customer-visible data domain — reuses `customers` + `accountLinks`. The remaining account-takeover-sensitive pieces stay deferred (see below): real out-of-band code delivery, deep-link/QR onboarding, and repairing a revoked link from chat. +- ~~**Cross-channel bridge: web login for a chat-registered account**~~ — **Shipped 2026-06-10** (completes the registration story above). A customer who registered over a chat channel (e.g. WhatsApp) has a `customers` + `accountLinks` identity but **no web credential**, so they could not sign in to the storefront. A new web **"set up web login for an existing account"** flow closes that: on `/register/link`, the customer supplies a new username + password + their **`accountCode`**, and `linkWebCredentialToExistingCustomer` verifies the code, then attaches a `Credential` + a `web` `AccountLink` to the **existing** `customerId` (so the web session resolves to the same customer, same cart/orders). This is the **web-side symmetric twin** of the chat `shop_account_link_existing` tool, gated by the **same `accountCode`** and so the **same demo-grade security level** — it is explicitly **not** stronger identity proof (see the deferred note below). Refuses an unrecognized code, a customer that **already has a web login** (one credential per account), and a taken username; writes an `account.web_link_existing` audit log. No new data domain — reuses `credentials` + `accountLinks`. The `account-registration` skill now surfaces the customer's `accountCode` after a chat signup so they know what to enter. + ### Deferred (out of scope) Explicitly **not** part of the prototype. @@ -138,7 +140,7 @@ Explicitly **not** part of the prototype. - **Restock / back-in-stock alerts** — a *customer-facing* outbound notification; still deferred (consent/spam, and it targets customers). The owner-only outbound channel shipped 2026-06-10 (above) unblocks the infrastructure, but restock alerts remain out of scope because they message customers, not the owner. - **Real payment / charging (a *paid* checkout)** — the **mock** checkout (cart → unpaid `order`) shipped 2026-06-09 (in scope, above); what stays out of scope is anything that moves money: card capture, payment processing, charges/refunds-to-card. The demo places orders with no payment. - **Storefront auth extras** — login + signup **shipped 2026-06-09** (in scope, above). Still deferred: password reset / email verification and OAuth / social login (no email or outbound channel in the mock). -- **Out-of-band verification-code delivery and link *repair*** — self-service registration + code-gated linking of an existing account **shipped 2026-06-09** (in scope, above). What remains deferred: *delivering* a one-time code out-of-band (we have no async outbound channel — same blocker as restock alerts), and repairing a **revoked** link from chat. Linking a pre-existing account **without** a verification code stays out of scope (the unverified account-takeover path). The demo uses a per-account `accountCode` as the code stand-in. +- **Out-of-band verification-code delivery and link *repair*** — self-service registration + code-gated linking of an existing account (over chat **and**, as of 2026-06-10, over the web — both gated by `accountCode`) **shipped** (in scope, above). What remains deferred: *delivering* a one-time code out-of-band (we have no async outbound channel — same blocker as restock alerts), and repairing a **revoked** link from chat. Linking a pre-existing account **without** a verification code stays out of scope (the unverified account-takeover path). The demo uses a per-account `accountCode` as the code stand-in, so all `accountCode`-gated linking (chat or web) is **demo-grade by design** — a real deployment would deliver a one-time code to the contact on file. - **Loyalty / points / gift balance** — not in the data model and flagged as cash-equivalent high-risk; out of scope. - **Customer-facing proactive / outbound messaging** (post-purchase check-ins, review requests, abandoned-cart nudges) — still deferred. **Owner-facing** proactive shipped 2026-06-10 (the scheduled ops digest, above), but messaging *customers* proactively raises consent/spam concerns and is out of scope; both the owner-only email channel and the scheduled digest only ever reach the owner. diff --git a/skills/account-registration/SKILL.md b/skills/account-registration/SKILL.md index 81bbf75..30fc576 100644 --- a/skills/account-registration/SKILL.md +++ b/skills/account-registration/SKILL.md @@ -14,7 +14,7 @@ Customers speak naturally: "sign me up", "I want to make an account", "register 1. Check whether the message should be handled by `sentiment-router` first (anger, request for a human, harm, legal/chargeback threats). If so, escalate instead of registering. 2. Confirm the sender is actually unlinked with `shop_customer_lookup` using the channel identity. If it already resolves to a customer, tell them they are already set up as that name and continue with their original request — do not register again. 3. If unlinked, ask whether they want to **create a new account** or **link an existing account**: - - **New account:** ask the name they want on the account, read it back to confirm, then call `shop_account_register` with the channel identity and that name. On success, welcome them and continue with whatever they originally wanted. + - **New account:** ask the name they want on the account, read it back to confirm, then call `shop_account_register` with the channel identity and that name. On success, welcome them and continue with whatever they originally wanted. The result includes an **`accountCode`** — read it back to them once and let them know they can use it to set up a login on the website (the "Set up web login" option), so their web cart and orders match this account. Share the code only with the sender who just registered, and never invent or guess one. - **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. diff --git a/src/cli/shop-eval.ts b/src/cli/shop-eval.ts index bb4168a..65be12d 100644 --- a/src/cli/shop-eval.ts +++ b/src/cli/shop-eval.ts @@ -45,6 +45,7 @@ import { listOrdersOps, resolveHandoffStatus, linkExistingAccountForChannel, + linkWebCredentialToExistingCustomer, listReturnsForChannel, lookupCustomerByChannel, previewAddItemForChannel, @@ -1190,6 +1191,57 @@ async function main(): Promise { assert(!badName.ok, "a username with spaces/symbols must be refused"); }); + group("Cross-channel bridge: web login for a chat-registered account"); + + await test("a chat-registered account claims a web login via its code → SAME customer", async () => { + // The martin case: register over a chat channel, get an accountCode, then set up + // a web login with it. The web login must land on the SAME customer (cart/orders). + const reg = await registerNewCustomerForChannel(CH, "eval-wa-user", "Wanda"); + assert(reg.ok && reg.data?.accountCode, reg.error ?? "chat registration should return an accountCode"); + const code = reg.data!.accountCode; + const chatCustomer = reg.data!.customerId; + + const link = await linkWebCredentialToExistingCustomer("wanda", "webpass123", code); + assert( + link.ok && link.data?.customerId === chatCustomer, + link.error ?? "the web login must attach to the SAME existing customer, not a new one" + ); + assert(link.data?.channel === "web" && link.data?.externalUserId === "wanda", "a web account-link must be created"); + + // The web login verifies and resolves to the same customer. + const login = await verifyWebCredential("wanda", "webpass123"); + assert(login.ok && login.data?.customerId === chatCustomer, "the web login must resolve to the chat-registered customer"); + + const db = await readShopDb(); + assert(db.customers.filter((c) => c.id === chatCustomer).length === 1, "no duplicate customer may be created"); + assert(!JSON.stringify(db.credentials).includes("webpass123"), "the plaintext password must NEVER be stored"); + const logs = await listActionLogs(chatCustomer, 20); + assert( + logs.data?.some((log) => log.type === "account.web_link_existing" && log.status === "success"), + "claiming a web login must write a success audit log" + ); + }); + + await test("claiming a web login with a wrong code is refused and creates nothing", async () => { + const before = (await readShopDb()).credentials.length; + const link = await linkWebCredentialToExistingCustomer("nobody", "webpass123", "NOPE-0000"); + assert(!link.ok && link.error?.includes("not recognized"), "an unrecognized code must be refused generically"); + assert((await readShopDb()).credentials.length === before, "a refused claim must create no credential"); + }); + + await test("an account that already has a web login cannot claim a second", async () => { + // Lin already has the seeded `lin` web login; her code must not mint another. + const link = await linkWebCredentialToExistingCustomer("lin-again", "webpass123", LIN_ACCOUNT_CODE); + assert(!link.ok && link.error?.includes("already has a web login"), "an account with a web login must be refused"); + }); + + await test("claiming a web login with a taken username is refused", async () => { + const reg = await registerNewCustomerForChannel(CH, "eval-wa-user2", "Wendy"); + assert(reg.ok && reg.data?.accountCode, reg.error ?? "registration should succeed"); + const link = await linkWebCredentialToExistingCustomer("lin", "webpass123", reg.data!.accountCode); + assert(!link.ok && link.error?.includes("already taken"), "a taken username must be refused even with a valid code"); + }); + group("Checkout (mock — cart → order, no payment)"); await test("an empty cart cannot be checked out", async () => { diff --git a/src/shop/service.ts b/src/shop/service.ts index 02c3919..9ce56e3 100644 --- a/src/shop/service.ts +++ b/src/shop/service.ts @@ -319,7 +319,7 @@ export async function registerNewCustomerForChannel( channel: string, externalUserId: string, displayName: string -): Promise> { +): Promise> { const db = await readShopDb(); const existing = resolveLinkedCustomer(db, channel, externalUserId); @@ -335,10 +335,11 @@ export async function registerNewCustomerForChannel( return { ok: false, error: "Please share the name to register the account under." }; } + const accountCode = generateAccountCode(db); const customer: Customer = { id: `customer_${randomUUID()}`, displayName: trimmedName, - accountCode: generateAccountCode(db) + accountCode }; const accountLink: AccountLink = { id: `link_${randomUUID()}`, @@ -361,7 +362,10 @@ export async function registerNewCustomerForChannel( }); await writeShopDb(db); - return { ok: true, data: buildLinkedCustomer(customer, accountLink) }; + // Surface the accountCode here (the shared LinkedCustomer view omits it) so the + // agent can read it back to the customer — they need it to set up a web login + // for this account later (see linkWebCredentialToExistingCustomer). + return { ok: true, data: { ...buildLinkedCustomer(customer, accountLink), accountCode } }; } // Link the caller's channel identity to an EXISTING customer account, gated by @@ -482,6 +486,82 @@ export async function registerWebAccount( return { ok: true, data: buildLinkedCustomer(customer, accountLink) }; } +// Attach a storefront web login to an EXISTING customer (e.g. one who registered +// over a chat channel and so has a customer + account-link but no web credential), +// gated by their accountCode. This is the web-side symmetric twin of +// linkExistingAccountForChannel: it verifies the code, then creates a Credential + +// a "web" AccountLink bound to the EXISTING customerId, so the web session resolves +// to the same customer (same cart/orders). The accountCode is a DEMO-GRADE stand-in +// for an out-of-band one-time code (see ARCHITECTURE §5) — not stronger identity +// proof. Refuses an unrecognized code, a customer that already has a web login (one +// credential per account), and a taken username. Web-only; not an MCP tool. +export async function linkWebCredentialToExistingCustomer( + username: string, + password: string, + accountCode: string +): Promise> { + const db = await readShopDb(); + + const normalizedUsername = normalize(username); + if (!normalizedUsername) { + return { ok: false, error: "Please choose a username." }; + } + if (!/^[a-z0-9._-]+$/.test(normalizedUsername)) { + return { ok: false, error: "Username can use only letters, numbers, dots, underscores, and hyphens." }; + } + if (password.length < MIN_PASSWORD_LENGTH) { + return { ok: false, error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters.` }; + } + const trimmedCode = accountCode.trim(); + if (!trimmedCode) { + return { ok: false, error: "Please share your account verification code." }; + } + if (findCredentialByUsername(db, normalizedUsername)) { + return { ok: false, error: "That username is already taken." }; + } + + const customer = findCustomerByAccountCode(db, trimmedCode); + if (!customer) { + return { ok: false, error: "That verification code was not recognized." }; + } + // One web login per account. If this customer already has a web credential, this is + // the wrong flow (and re-binding a new password would be a takeover vector). Refuse. + if (db.credentials.some((credential) => credential.customerId === customer.id)) { + return { ok: false, error: "This account already has a web login. Try signing in instead." }; + } + + const accountLink: AccountLink = { + id: `link_${randomUUID()}`, + customerId: customer.id, + channel: "web", + externalUserId: normalizedUsername, + status: "linked", + linkedAt: nowIso() + }; + const salt = randomBytes(16).toString("hex"); + const credential: Credential = { + customerId: customer.id, + username: normalizedUsername, + salt, + hash: hashPassword(password, salt), + createdAt: nowIso() + }; + + db.accountLinks.push(accountLink); + db.credentials.push(credential); + + addLog(db, { + type: "account.web_link_existing", + status: "success", + customerId: customer.id, + summary: `Set up web login @${normalizedUsername} for existing customer ${customer.displayName}.`, + metadata: { accountLinkId: accountLink.id, channel: accountLink.channel } + }); + + await writeShopDb(db); + return { ok: true, data: buildLinkedCustomer(customer, accountLink) }; +} + // Verify a storefront login. Returns the linked customer on success; a single // generic error otherwise. Hashes even when the username is unknown so a missing // user and a wrong password are indistinguishable and roughly equal-time. diff --git a/web/app/register/link/page.tsx b/web/app/register/link/page.tsx new file mode 100644 index 0000000..67cff03 --- /dev/null +++ b/web/app/register/link/page.tsx @@ -0,0 +1,23 @@ +import { redirect } from "next/navigation"; + +import { AuthForm } from "@/components/auth/AuthForm"; +import { linkExistingAccount } from "@/lib/auth/actions"; +import { getCurrentCustomerName } from "@/lib/shop"; + +export default async function LinkAccountPage() { + // Resolution-based (see login/register): a stale cookie should not bounce. + if (await getCurrentCustomerName()) redirect("/"); + + return ( +
+
+

Set up web login

+

+ Already registered with us in chat? Add a username and password to your existing account using + your account code, and you’ll keep the same cart and orders. +

+
+ +
+ ); +} diff --git a/web/components/auth/AuthForm.tsx b/web/components/auth/AuthForm.tsx index 5e9fa8f..117d209 100644 --- a/web/components/auth/AuthForm.tsx +++ b/web/components/auth/AuthForm.tsx @@ -9,12 +9,16 @@ import { Label } from "@/components/ui/label"; import type { AuthFormState } from "@/lib/auth/actions"; type AuthAction = (prev: AuthFormState, formData: FormData) => Promise; +type AuthMode = "login" | "register" | "link"; -// Shared login/register form. Uses useActionState so a failed action renders its -// error inline; a successful action redirects server-side (never returns here). -export function AuthForm({ mode, action }: { mode: "login" | "register"; action: AuthAction }) { +// Shared login / register / link form. Uses useActionState so a failed action +// renders its error inline; a successful action redirects server-side (never +// returns here). The "link" mode sets up a web login for an account that already +// exists (e.g. registered over WhatsApp), gated by its verification code. +export function AuthForm({ mode, action }: { mode: AuthMode; action: AuthAction }) { const [state, formAction, pending] = useActionState(action, {}); const isRegister = mode === "register"; + const isLink = mode === "link"; return (
@@ -27,6 +31,9 @@ export function AuthForm({ mode, action }: { mode: "login" | "register"; action:
+ {isLink && ( +

Choose a username for signing in on the web.

+ )}
@@ -34,11 +41,22 @@ export function AuthForm({ mode, action }: { mode: "login" | "register"; action: id="password" name="password" type="password" - autoComplete={isRegister ? "new-password" : "current-password"} + autoComplete={isRegister || isLink ? "new-password" : "current-password"} required /> - {isRegister &&

At least 8 characters.

} + {(isRegister || isLink) && ( +

At least 8 characters.

+ )}
+ {isLink && ( +
+ + +

+ The code from when you registered with us in chat (for example over WhatsApp). +

+
+ )} {state.error && (

@@ -50,29 +68,64 @@ export function AuthForm({ mode, action }: { mode: "login" | "register"; action: {pending ? isRegister ? "Creating…" - : "Signing in…" + : isLink + ? "Setting up…" + : "Signing in…" : isRegister ? "Create account" - : "Sign in"} + : isLink + ? "Set up web login" + : "Sign in"} -

- {isRegister ? ( +

+ {isLink ? ( + <> + + Want a fresh account?{" "} + + Create one + + + + Already set up?{" "} + + Sign in + + + + ) : isRegister ? ( <> - Already have an account?{" "} - - Sign in - + + Already have an account?{" "} + + Sign in + + + + Registered with us in chat?{" "} + + Set up web login + + ) : ( <> - New here?{" "} - - Create an account - + + New here?{" "} + + Create an account + + + + Registered with us in chat?{" "} + + Set up web login + + )} -

+
); } diff --git a/web/lib/auth/actions.ts b/web/lib/auth/actions.ts index 244206a..9c66acf 100644 --- a/web/lib/auth/actions.ts +++ b/web/lib/auth/actions.ts @@ -5,7 +5,11 @@ // clear the session cookie, then redirect. No parallel auth logic, no raw DB. import { redirect } from "next/navigation"; -import { registerWebAccount, verifyWebCredential } from "@shop/service.js"; +import { + linkWebCredentialToExistingCustomer, + registerWebAccount, + verifyWebCredential, +} from "@shop/service.js"; import { clearSession, setSession } from "./session"; @@ -47,6 +51,25 @@ export async function register( redirect("/"); } +// Set up a web login for an account that already exists (e.g. one registered over +// WhatsApp), gated by the account's verification code. Attaches the credential to +// the EXISTING customer, so the session lands on the same cart/orders. +export async function linkExistingAccount( + _prev: AuthFormState, + formData: FormData, +): Promise { + const username = String(formData.get("username") ?? ""); + const password = String(formData.get("password") ?? ""); + const accountCode = String(formData.get("accountCode") ?? ""); + + const result = await linkWebCredentialToExistingCustomer(username, password, accountCode); + if (!result.ok) { + return { error: result.error ?? "Could not set up your web login." }; + } + await setSession(result.data!.externalUserId); + redirect("/"); +} + export async function logout(): Promise { await clearSession(); redirect("/");