An admin can disable a customer's account at their discretion, and re-enable it.
Scope
There is currently no disabled, suspended, or inactive concept anywhere in the schema — this introduces the first one.
The important part is that disabling must apply to existing sessions, not just new logins. A customer holding a valid rd_session cookie has to lose access at their next request; blocking only the login form would leave an already-signed-in customer fully operational.
Design questions to resolve before implementing
What happens to items the customer currently holds in their cart — released immediately, or left to the expiry sweep?
Is the customer told they have been disabled, or does it present as a generic sign-in failure?
Does disabling affect their order history and data-export/deletion rights? (GDPR self-service endpoints exist today.)
Does a disabled account still receive marketing email, or is it excluded regardless of consent?
Why this is worth doing early
It defines a rule — a disabled account cannot authenticate — that password reset, favorites, and passkey login all have to honor. Establishing it once is cheaper than retrofitting it into each.
Split out of #31 (item 3).
## Goal
An admin can disable a customer's account at their discretion, and re-enable it.
## Scope
There is currently no disabled, suspended, or inactive concept anywhere in the schema — this introduces the first one.
The important part is that disabling must apply to **existing sessions**, not just new logins. A customer holding a valid `rd_session` cookie has to lose access at their next request; blocking only the login form would leave an already-signed-in customer fully operational.
## Design questions to resolve before implementing
- What happens to items the customer currently holds in their cart — released immediately, or left to the expiry sweep?
- Is the customer told they have been disabled, or does it present as a generic sign-in failure?
- Does disabling affect their order history and data-export/deletion rights? (GDPR self-service endpoints exist today.)
- Does a disabled account still receive marketing email, or is it excluded regardless of consent?
## Why this is worth doing early
It defines a rule — a disabled account cannot authenticate — that password reset, favorites, and passkey login all have to honor. Establishing it once is cheaper than retrofitting it into each.
Released immediately, in the same transaction as the disable
What the customer sees at sign-in
"This account has been disabled" — explicit, not a generic credential failure
Self-service GDPR export and deletion
Blocked along with everything else; such requests are handled manually
Storage
disabled_at TIMESTAMPTZ NULL rather than a boolean — reversible, and records when for free
Why release the held items
A disabled account cannot check out, so leaving its reservations in place keeps one-of-a-kind stock off the storefront for up to the cart expiry window (currently 24 hours) for no purpose. The release reuses the logic already built for #27.
Why an explicit sign-in message
It confirms the address has an account, which is an enumeration signal — and that sits awkwardly beside the deliberately non-enumerating password reset in #32. The trade was made the other way here because the alternative is worse for a real person: a disabled customer given "invalid email or password" will reset their password, succeed at the reset, still be locked out, and reasonably conclude the site is broken. Note /register already reveals account existence through its 409, so the enumeration surface is not new.
Consequence worth recording
Disabling blocks every authenticated endpoint, including GET /api/customers/me/export and DELETE /api/customers/me. A right that is currently self-service becomes dependent on an admin servicing it by hand. That is acceptable at this volume, but worth checking the privacy policy does not promise self-service access unconditionally.
Marketing email was raised and is largely moot in practice: the only scheduled send is the cart reminder, and disabling empties the cart, so there is nothing to remind about. If campaign sending is ever added, disabled accounts should be excluded regardless of their consent flag — the consent record itself stays untouched.
Enforcement
attachCustomer currently validates the session token and expiry alone; it never reads the customer row. So disabling has to be enforced there, not only at login — otherwise an existing rd_session cookie continues to work. Registration, login, and password reset all mint sessions, so a single check in the middleware covers every path rather than three separate ones. Sessions are also deleted at the moment of disabling, so eviction is immediate rather than waiting for the next request.
## Design decisions
| Question | Decision |
| --- | --- |
| Items held in the cart | **Released immediately**, in the same transaction as the disable |
| What the customer sees at sign-in | **"This account has been disabled"** — explicit, not a generic credential failure |
| Self-service GDPR export and deletion | **Blocked along with everything else**; such requests are handled manually |
| Storage | `disabled_at TIMESTAMPTZ NULL` rather than a boolean — reversible, and records *when* for free |
### Why release the held items
A disabled account cannot check out, so leaving its reservations in place keeps one-of-a-kind stock off the storefront for up to the cart expiry window (currently 24 hours) for no purpose. The release reuses the logic already built for #27.
### Why an explicit sign-in message
It confirms the address has an account, which is an enumeration signal — and that sits awkwardly beside the deliberately non-enumerating password reset in #32. The trade was made the other way here because the alternative is worse for a real person: a disabled customer given "invalid email or password" will reset their password, succeed at the reset, still be locked out, and reasonably conclude the site is broken. Note `/register` already reveals account existence through its `409`, so the enumeration surface is not new.
### Consequence worth recording
Disabling blocks every authenticated endpoint, including `GET /api/customers/me/export` and `DELETE /api/customers/me`. A right that is currently self-service becomes dependent on an admin servicing it by hand. That is acceptable at this volume, but **worth checking the privacy policy does not promise self-service access unconditionally**.
Marketing email was raised and is largely moot in practice: the only scheduled send is the cart reminder, and disabling empties the cart, so there is nothing to remind about. If campaign sending is ever added, disabled accounts should be excluded regardless of their consent flag — the consent record itself stays untouched.
### Enforcement
`attachCustomer` currently validates the session token and expiry alone; it never reads the customer row. So disabling has to be enforced there, not only at login — otherwise an existing `rd_session` cookie continues to work. Registration, login, and password reset all mint sessions, so a single check in the middleware covers every path rather than three separate ones. Sessions are also deleted at the moment of disabling, so eviction is immediate rather than waiting for the next request.
Branch feature/disable-customer-account, one commit, not yet pushed. Includes a migration, so QA and production will apply it on container start.
Enforcement
attachCustomer now joins customers and refuses a disabled account. That was the substantive part: it previously validated only the session token and its expiry and never read the customer row, so an existing rd_session cookie would have kept working for up to 30 days. Register, login and password reset all mint sessions, so one check in the middleware covers every path instead of three. Disabling also deletes the session rows outright, so eviction is immediate rather than at the next request.
Behaviour
Sign-in while disabled
403 this account has been disabled
Wrong password while disabled
Still 401 invalid email or password — the disable check runs only after the password verifies, so it is not a bulk membership oracle
Existing sessions
Deleted at the moment of disabling
Held items
Released in the same transaction, guarded on reserved so a sold item is never resurrected
Password reset request
Answers 200 as always, but issues no token
Reset token issued before the disable
403 — it cannot be used to mint a session
Re-enabling
Restores sign-in; released items are not returned, as they may since have sold
Registering the same address again
Still 409, so disabling cannot be undone by signing up again
Admin UI
A Status column (ACTIVE / DISABLED) and a Disable / Re-enable button per row. The confirmation names the consequences before you commit: that they will be signed out everywhere, how many reserved items will be released, and that self-service data export and deletion stop working.
GDPR consequence, restated
Disabling blocks every authenticated endpoint including GET /api/customers/me/export and DELETE /api/customers/me. Worth checking the privacy policy does not promise unconditional self-service access, since servicing those requests now depends on an admin doing it by hand.
Unrelated bug found and fixed
The e2e run surfaced a genuine defect in the admin inventory filters from #27: the price fields fire a request per keystroke with no sequencing, so an older response could arrive after a newer one and repaint stale rows. Typing 500 issues three requests, and whichever resolved last won. Only the most recently issued request may now set state. The storefront masks the same hazard behind a debounce; the admin had none.
Verification
Suite
Result
Backend unit
52 passed
Backend integration
104 passed (15 new)
Frontend e2e
57 passed (4 new), stable over three consecutive runs
tsc --noEmit backend / frontend
clean
npm run build
clean
Integration coverage includes session eviction, item release, the sold-item guard, the reset-token interaction, re-registration, and that re-enabling does not restore released items.
Next
This unblocks #34 (favorites) and #36 (passkeys), both of which have to honour the same rule — which now exists in one place rather than needing to be written into each.
## Implemented
Branch `feature/disable-customer-account`, one commit, not yet pushed. Includes a migration, so QA and production will apply it on container start.
### Enforcement
`attachCustomer` now joins `customers` and refuses a disabled account. That was the substantive part: it previously validated only the session token and its expiry and never read the customer row, so an existing `rd_session` cookie would have kept working for up to 30 days. Register, login and password reset all mint sessions, so one check in the middleware covers every path instead of three. Disabling also deletes the session rows outright, so eviction is immediate rather than at the next request.
### Behaviour
| | |
| --- | --- |
| Sign-in while disabled | `403 this account has been disabled` |
| Wrong password while disabled | Still `401 invalid email or password` — the disable check runs only after the password verifies, so it is not a bulk membership oracle |
| Existing sessions | Deleted at the moment of disabling |
| Held items | Released in the same transaction, guarded on `reserved` so a sold item is never resurrected |
| Password reset request | Answers `200` as always, but issues no token |
| Reset token issued before the disable | `403` — it cannot be used to mint a session |
| Re-enabling | Restores sign-in; released items are **not** returned, as they may since have sold |
| Registering the same address again | Still `409`, so disabling cannot be undone by signing up again |
### Admin UI
A **Status** column (ACTIVE / DISABLED) and a Disable / Re-enable button per row. The confirmation names the consequences before you commit: that they will be signed out everywhere, how many reserved items will be released, and that self-service data export and deletion stop working.
### GDPR consequence, restated
Disabling blocks every authenticated endpoint including `GET /api/customers/me/export` and `DELETE /api/customers/me`. **Worth checking the privacy policy does not promise unconditional self-service access**, since servicing those requests now depends on an admin doing it by hand.
### Unrelated bug found and fixed
The e2e run surfaced a genuine defect in the admin inventory filters from #27: the price fields fire a request per keystroke with no sequencing, so an older response could arrive after a newer one and repaint stale rows. Typing `500` issues three requests, and whichever resolved last won. Only the most recently issued request may now set state. The storefront masks the same hazard behind a debounce; the admin had none.
### Verification
| Suite | Result |
| --- | --- |
| Backend unit | 52 passed |
| Backend integration | 104 passed (15 new) |
| Frontend e2e | 57 passed (4 new), stable over three consecutive runs |
| `tsc --noEmit` backend / frontend | clean |
| `npm run build` | clean |
Integration coverage includes session eviction, item release, the sold-item guard, the reset-token interaction, re-registration, and that re-enabling does not restore released items.
### Next
This unblocks #34 (favorites) and #36 (passkeys), both of which have to honour the same rule — which now exists in one place rather than needing to be written into each.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Split out of #31 (item 3).
Goal
An admin can disable a customer's account at their discretion, and re-enable it.
Scope
There is currently no disabled, suspended, or inactive concept anywhere in the schema — this introduces the first one.
The important part is that disabling must apply to existing sessions, not just new logins. A customer holding a valid
rd_sessioncookie has to lose access at their next request; blocking only the login form would leave an already-signed-in customer fully operational.Design questions to resolve before implementing
Why this is worth doing early
It defines a rule — a disabled account cannot authenticate — that password reset, favorites, and passkey login all have to honor. Establishing it once is cheaper than retrofitting it into each.
Design decisions
disabled_at TIMESTAMPTZ NULLrather than a boolean — reversible, and records when for freeWhy release the held items
A disabled account cannot check out, so leaving its reservations in place keeps one-of-a-kind stock off the storefront for up to the cart expiry window (currently 24 hours) for no purpose. The release reuses the logic already built for #27.
Why an explicit sign-in message
It confirms the address has an account, which is an enumeration signal — and that sits awkwardly beside the deliberately non-enumerating password reset in #32. The trade was made the other way here because the alternative is worse for a real person: a disabled customer given "invalid email or password" will reset their password, succeed at the reset, still be locked out, and reasonably conclude the site is broken. Note
/registeralready reveals account existence through its409, so the enumeration surface is not new.Consequence worth recording
Disabling blocks every authenticated endpoint, including
GET /api/customers/me/exportandDELETE /api/customers/me. A right that is currently self-service becomes dependent on an admin servicing it by hand. That is acceptable at this volume, but worth checking the privacy policy does not promise self-service access unconditionally.Marketing email was raised and is largely moot in practice: the only scheduled send is the cart reminder, and disabling empties the cart, so there is nothing to remind about. If campaign sending is ever added, disabled accounts should be excluded regardless of their consent flag — the consent record itself stays untouched.
Enforcement
attachCustomercurrently validates the session token and expiry alone; it never reads the customer row. So disabling has to be enforced there, not only at login — otherwise an existingrd_sessioncookie continues to work. Registration, login, and password reset all mint sessions, so a single check in the middleware covers every path rather than three separate ones. Sessions are also deleted at the moment of disabling, so eviction is immediate rather than waiting for the next request.Implemented
Branch
feature/disable-customer-account, one commit, not yet pushed. Includes a migration, so QA and production will apply it on container start.Enforcement
attachCustomernow joinscustomersand refuses a disabled account. That was the substantive part: it previously validated only the session token and its expiry and never read the customer row, so an existingrd_sessioncookie would have kept working for up to 30 days. Register, login and password reset all mint sessions, so one check in the middleware covers every path instead of three. Disabling also deletes the session rows outright, so eviction is immediate rather than at the next request.Behaviour
403 this account has been disabled401 invalid email or password— the disable check runs only after the password verifies, so it is not a bulk membership oraclereservedso a sold item is never resurrected200as always, but issues no token403— it cannot be used to mint a session409, so disabling cannot be undone by signing up againAdmin UI
A Status column (ACTIVE / DISABLED) and a Disable / Re-enable button per row. The confirmation names the consequences before you commit: that they will be signed out everywhere, how many reserved items will be released, and that self-service data export and deletion stop working.
GDPR consequence, restated
Disabling blocks every authenticated endpoint including
GET /api/customers/me/exportandDELETE /api/customers/me. Worth checking the privacy policy does not promise unconditional self-service access, since servicing those requests now depends on an admin doing it by hand.Unrelated bug found and fixed
The e2e run surfaced a genuine defect in the admin inventory filters from #27: the price fields fire a request per keystroke with no sequencing, so an older response could arrive after a newer one and repaint stale rows. Typing
500issues three requests, and whichever resolved last won. Only the most recently issued request may now set state. The storefront masks the same hazard behind a debounce; the admin had none.Verification
tsc --noEmitbackend / frontendnpm run buildIntegration coverage includes session eviction, item release, the sold-item guard, the reset-token interaction, re-registration, and that re-enabling does not restore released items.
Next
This unblocks #34 (favorites) and #36 (passkeys), both of which have to honour the same rule — which now exists in one place rather than needing to be written into each.