Files
web_sport/docs/adr/0023-accounts-adopt-guest-orders-and-never-gate-checkout.md
T
2026-08-13 23:20:23 +07:00

5.2 KiB

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.