104 lines
5.2 KiB
Markdown
104 lines
5.2 KiB
Markdown
# ADR-0023: Accounts adopt guest orders, and never gate checkout
|
|
|
|
- **Status:** Accepted
|
|
- **Date:** 2026-08-13
|
|
|
|
## Context
|
|
|
|
Guest checkout has been the only way to buy since M5, and it works: the shopper
|
|
types an email, the order carries it, and a capability URL lets them look it up
|
|
later. Adding accounts risks two familiar regressions.
|
|
|
|
The first is gating. It is tempting to make checkout require an account,
|
|
because then every order has a customer and the data model is tidy. It also
|
|
costs conversions from exactly the people least willing to spend time on a
|
|
store they have not bought from before.
|
|
|
|
The second is amnesia. A shopper who bought twice as a guest and then registers
|
|
with the same email has an order history the store already holds and refuses to
|
|
show them, because those rows have `customer_id = NULL`. From the outside that
|
|
looks like the store lost their orders.
|
|
|
|
## Decision
|
|
|
|
**Checkout never requires an account.** `POST /checkout/orders` stays public. A
|
|
signed-in shopper's order is _attached_ to their account; a guest's is not.
|
|
Same endpoint, same flow, one nullable column different.
|
|
|
|
**A public route may recognise a token without requiring one.** `AccessTokenGuard`
|
|
gained an optional branch: on a `@Public()` route it verifies a bearer token if
|
|
one is present and populates `request.actor`, and otherwise proceeds. A missing,
|
|
malformed or expired token leaves the request anonymous rather than rejected.
|
|
|
|
The alternatives were worse. Taking a customer id from the request body is an
|
|
account-takeover primitive. Re-verifying the token by hand in the checkout
|
|
controller duplicates the guard, and the copy is the one that will rot.
|
|
|
|
**Registration adopts guest orders by email.** `UPDATE orders SET customer_id =
|
|
… WHERE customer_id IS NULL AND lower(email) = lower(…)`. This is safe _only_
|
|
at registration, where the password was just set by whoever controls the
|
|
address — the same assumption every password-reset flow already rests on. It
|
|
deliberately does not run at login, where it would let someone who changed
|
|
their account email vacuum up a stranger's orders.
|
|
|
|
**Every account route is scoped to the session.** There is no
|
|
`GET /customers/:id` anywhere. Profile, addresses, wishlist and order history
|
|
all read `actor.userId` and take no id from the client, so horizontal privilege
|
|
escalation is impossible by construction rather than by remembering an
|
|
ownership check in each handler.
|
|
|
|
**Account routes require the storefront audience.** An admin token is refused
|
|
(403), even though it is a valid credential. A back-office token is for
|
|
back-office endpoints; letting it act as a customer would blur which surface an
|
|
action came from.
|
|
|
|
**The access token lives in memory only.** Same as the admin (ADR-0015): never
|
|
localStorage, never a readable cookie. The httpOnly refresh cookie is what
|
|
survives a reload, and one shared in-flight refresh promise stops concurrent
|
|
401s from tripping reuse detection and revoking the family.
|
|
|
|
**The wishlist is keyed on product, not variant.** Saving a jacket means "this
|
|
one, later" — not "this one in black, size M". A variant key would kill a saved
|
|
item when a colourway is discontinued and list the same jacket five times.
|
|
|
|
## Consequences
|
|
|
|
Guest checkout, signed-in checkout, and a checkout carrying a garbage token all
|
|
place orders successfully; only the middle one attaches to a customer. That is
|
|
three paths through one endpoint, and all three are exercised.
|
|
|
|
A shopper who registers with an email they previously used as a guest sees
|
|
those orders immediately. Verified: order SP-000001, placed as a guest, appeared
|
|
in the account's history the moment the account was created.
|
|
|
|
Email is not editable from the profile. Changing the address an account signs in
|
|
with is an identity change needing its own verified flow, and a `PATCH` that
|
|
quietly accepted a new email would be an account-takeover primitive of exactly
|
|
the kind this ADR is trying to avoid. The field is read-only and says so.
|
|
|
|
The address book maintains "at most one default" in application code, not with a
|
|
partial unique index — an index would reject the intermediate state of a swap.
|
|
Deleting the default promotes another, so checkout always has something to
|
|
prefill.
|
|
|
|
Client-side route guarding on `/account` is presentation only: it decides what
|
|
to _show_. The API re-checks every request, which is what decides what is
|
|
_allowed_. Middleware could not do better without calling the API on every
|
|
navigation, because only the API can validate the httpOnly cookie.
|
|
|
|
## Alternatives considered
|
|
|
|
**Require an account to check out.** Tidier data, fewer orders. Rejected.
|
|
|
|
**Merge guest orders at login rather than registration.** Catches the shopper
|
|
who registered with a different address, and lets anyone who changes their
|
|
account email claim orders that were never theirs. Rejected.
|
|
|
|
**A localStorage wishlist for anonymous shoppers.** Feels seamless and reads as
|
|
working right up until they switch device — which is precisely when they go
|
|
looking for the thing they saved. The button sends them to sign in instead.
|
|
|
|
**Keep the token in localStorage so it survives reloads directly.** Removes the
|
|
refresh round-trip on first paint, and hands any XSS a durable credential. The
|
|
in-memory + httpOnly split is the same trade the admin already made.
|