Stage M8
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
# 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.
|
||||
@@ -31,6 +31,7 @@ An ADR is immutable once accepted. If a decision changes, add a new ADR that sup
|
||||
| [0020](./0020-promotions-and-coupons-are-one-entity.md) | Promotions and coupons are one entity with one engine | Accepted |
|
||||
| [0021](./0021-a-review-is-anchored-to-an-order-line.md) | A review is anchored to an order line | Accepted |
|
||||
| [0022](./0022-editorial-content-is-markdown-not-a-page-builder.md) | Editorial content is Markdown, not a page builder | Accepted |
|
||||
| [0023](./0023-accounts-adopt-guest-orders-and-never-gate-checkout.md) | Accounts adopt guest orders, and never gate checkout | Accepted |
|
||||
|
||||
## Decisions deliberately NOT recorded yet
|
||||
|
||||
|
||||
Reference in New Issue
Block a user