Skip to content

feat(account): self-service + admin account deletion, key export, trial-abuse prevention - #873

Merged
fraseragain merged 11 commits into
developfrom
feat/account-delete
Nov 6, 2026
Merged

fraseragain merged 11 commits into
developfrom
feat/account-delete

Conversation

@fraseragain

Copy link
Copy Markdown
Contributor

Summary

  • DELETE /account (self-service) and DELETE /admin/organisation/delete (support-triggered) both run a shared deleteCustomerAccount pipeline: cancel any active Stripe subscription → delete the Logto user → record the deletion against the email → delete the customer row (FK cascades remove everything it owns). Each step's outcome is reported so a retry can resume sensibly; the deletion record is rolled back if the DB delete that's supposed to follow it doesn't actually happen.
  • POST /account/export-keys returns DID private keys (reusing the existing /did/export mechanism, per-DID so one failure doesn't block the rest) and the two payment-account private keys (new).
  • A new deletedAccountRecord table (no FK to customer, so it survives the cascade delete) blocks a fresh free trial for any email that's had an account deleted before — checked both in the automatic trial Stripe grants on first signup and in the explicit "start trial" endpoint.
  • Gated by new scopes: delete:account, export:account:keys, admin:organisation:delete. These need creating on the Logto tenant and granting to the relevant roles before this works end-to-end — not something this PR can do.

Known issue found during review, not introduced by this PR

While reviewing the admin-delete path's auth, I found the M2M customer-id auth strategy (auth-gaurd.ts → M2MCredsTokenUserInfoFetcher) passes the customer-id header value where the bearer token should go, so jwtVerify is called on a plain customer ID rather than a JWT. Confirmed locally that this throws immediately (JWSInvalid) regardless of the actual bearer token presented — meaning every M2M call to /admin/*, not just the new delete endpoint, currently fails with a 500. This is pre-existing (affects the already-shipped /admin/organisation/get and /admin/organisation/update too) and not part of this diff, but it blocks the admin-delete path from being usable until it's fixed. Flagging here since it's directly relevant to reviewing this PR's admin endpoint.

Test plan

  • Full unit suite passes (npm run test:unit) — 249 passing locally, 1 suite skipped (Postgres-gated)
  • Typecheck clean (tsc --noEmit)
  • Swagger regenerated
  • Create the three new Logto scopes and grant them appropriately before merging/deploying
  • Fix (or confirm already fixed elsewhere) the M2M auth wiring issue above before relying on the admin-delete endpoint

Refs #872

…t deletion

Adds DELETE /account (self-service) and DELETE /admin/organisation/delete
(support-triggered), both running the same deleteCustomerAccount pipeline:
cancel any active Stripe subscription, delete the Logto user, then delete
the customer row (FK cascades remove everything it owns). Stripe is
skipped when STRIPE_ENABLED isn't "true", so this works on staging
without a configured Stripe account.

Refs #872
…unt private keys

Returns the private keys for every DID the customer owns (reusing the
same path as /did/export/{did}, per-DID so one failure doesn't block
the rest) plus the two cosmos payment-account private keys (mainnet and
testnet fee-payer wallets), which weren't reachable through any existing
endpoint. Response is plaintext JSON over HTTPS, matching the existing
/did/export precedent; the caller (studio-portal) is expected to encrypt
it client-side with a user-supplied password before writing it to disk.

Gated by its own export:account:keys scope rather than read:account,
since it returns raw private key material.

Refs #872
…onses

DELETE /account and DELETE /admin/organisation/delete already returned
the full per-step AccountDeletionResult on failure, but no single
top-level error message -- awkward for API consumers (e.g. studio-portal)
that expect a GenericErrorResponse-shaped {error: string}. Adds
describeAccountDeletionFailure to pick out whichever step failed first.

Refs #872
…eted account

Adds a deletedAccountRecord table, written (keyed by email, no FK to
customer so it survives the cascade delete) as a new step in the
account-deletion pipeline, between the Logto delete and the DB delete --
if recording the deletion fails, the pipeline stops before the
irreversible DB delete, so the block can never be silently skipped.

Checked in both places a free trial can start:
- The automatic 30-day trial Stripe grants on every first signup
  (account-submitter.ts's assignDefaultPlan) is skipped for a
  previously-deleted email; the account is still created, just with no
  subscription, same as any new customer who hasn't picked a plan yet.
- The explicit POST /admin/subscription/create (trialMode: true)
  endpoint now rejects a previously-deleted email with 403, forcing
  them through the paid checkout flow instead.

The block applies to any email that has ever had an account deleted,
regardless of whether that account used a trial, paid, or neither.
Retained indefinitely, alongside the Stripe customer object itself
(cancelled but not deleted), for billing-history continuity.

Refs #872
…succeed

deleteCustomerAccount wrote the deletedAccountRecord (which permanently
blocks a future free trial for that email) before the DB delete, so the
block could never be silently skipped on success -- but didn't account
for the DB delete itself then failing or no-opping, which left a fully
intact account permanently blocked from ever trialing again with no way
to undo it.

DeletedAccountRecordService.record() now returns whatever record existed
for that email before it ran, and a new restore() re-establishes exactly
that prior state (not just a blind delete) if the DB delete that was
supposed to follow doesn't actually happen -- this matters because the
email may already have been blocked by a genuinely earlier deletion, and
a naive rollback would incorrectly erase that real block.

Also fixes a regression caught by the updated tests: dbDelete was never
being set to 'ok' on the success path after the rollback logic was added.

Refs #872
…etion

OrganisationController.delete is a destructive, irreversible action on
someone else's account, triggered by a privileged M2M credential rather
than the account owner -- unlike every other write in this file, it had
no eventTracker.notify() call recording who did what. Logs the target
customerId/email, the calling credential's scopes (the auth layer
doesn't currently surface a more specific caller identity -- see #874),
and the outcome, on both success and failure.

Refs #872
@fraseragain fraseragain changed the title Account deletion: self-service + admin delete, key export, trial-abuse prevention feat(account): self-service + admin account deletion, key export, trial-abuse prevention Nov 6, 2026
dubiouspip and others added 4 commits November 6, 2026 13:30
server.listen() previously fired before Connection.instance.connect()
settled, so early requests could hit a driver that was still
initializing and fail with TypeORMError: Driver not Connected instead
of a clean retry. app.ts now exposes the connect promise as dbReady,
and index.ts awaits it before listening.
The only place a failed DB connection surfaces was console.log, which
log collectors that only capture stderr by default (including
Playwright's webServer) silently drop. Switching to console.error so
a real connect failure is actually visible instead of looking
identical to success.
…ument

DeletedAccountRecordEntity's constructor called email.trim() directly
on its parameter. TypeORM instantiates entity classes with no
arguments while building metadata during DataSource.initialize(), so
that threw on undefined and aborted initialize() entirely -- which is
why every DB query failed with "Driver not Connected" regardless of
which endpoint, on every CI run since this entity was added. Every
other entity in this codebase only does plain assignment in its
constructor for exactly this reason.

Moves normalization to the call site in DeletedAccountRecordService,
which also fixes record() saving the row under the un-normalized
email while looking up "existing" under the normalized one.
@fraseragain
fraseragain merged commit ee327fd into develop Nov 6, 2026
12 checks passed
@fraseragain
fraseragain deleted the feat/account-delete branch November 6, 2026 15:32
cheqd-bot Bot pushed a commit that referenced this pull request Nov 6, 2026
## [3.21.0-develop.1](3.20.1-develop.1...3.21.0-develop.1) (2026-11-06)

### Features

* **account:** add an audit log entry for admin-triggered account deletion ([892e3b7](892e3b7)), closes [#874](#874) [#872](#872)
* **account:** add POST /account/export-keys for DID and payment-account private keys ([3fae1ab](3fae1ab)), closes [#872](#872)
* **account:** block a fresh free trial for an email with a prior deleted account ([d2c3a6c](d2c3a6c)), closes [#872](#872)
* **account:** hard-delete pipeline for self-service and admin account deletion ([0b65196](0b65196)), closes [#872](#872)
* **account:** include a summary error string on failed deletion responses ([cc54f44](cc54f44)), closes [#872](#872)
* **account:** self-service + admin account deletion, key export, trial-abuse prevention ([#873](#873)) ([ee327fd](ee327fd)), closes [#872](#872)

### Bug Fixes

* **account:** don't call a method on the entity constructor's own argument ([f0ce30e](f0ce30e))
* **account:** roll back the deletion record if the DB delete doesn't succeed ([145a907](145a907)), closes [#872](#872)
* **server:** log the DB connect failure to stderr, not stdout ([0c906d1](0c906d1))
* **server:** wait for the DB connection before accepting requests ([118e714](118e714))
@cheqd-bot

cheqd-bot Bot commented Nov 6, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 3.21.0-develop.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

fraseragain added a commit that referenced this pull request Nov 6, 2026
…ifier (#875)

## Summary
- `chooseUserFetcherStrategy` in `auth-gaurd.ts` constructed
`M2MCredsTokenUserInfoFetcher` with the `customer-id` header value
instead of the bearer token, so `jwtVerify` was verifying a plain
customer UUID as if it were a JWT — failing every M2M-authenticated
request with a 500, regardless of the actual bearer token presented.
- This affects `GET/POST /admin/organisation/get|update`, `DELETE
/admin/organisation/delete` (new in #873, unusable via M2M until now),
and one of `/account/faucet`'s three auth strategies.
- Adds a regression test through `APIGuard` itself (mocking `jose`'s
`jwtVerify`) asserting the bearer token is what gets verified — the
existing M2M integration test calls the controller method directly,
bypassing this code path entirely, which is how this went uncaught.

## Test plan
- [x] New test fails against the pre-fix code (verified locally by
reverting the fix and re-running)
- [x] New test passes with the fix applied
- [x] Full unit suite passes (250/250)
- [ ] Manually verify `DELETE /admin/organisation/delete` via a real M2M
token once deployed to staging

Fixes #874
cheqd-bot Bot pushed a commit that referenced this pull request Nov 6, 2026
## [3.21.0-develop.2](3.21.0-develop.1...3.21.0-develop.2) (2026-11-06)

### Bug Fixes

* **auth:** pass the bearer token, not customer-id, to the M2M JWT verifier ([#875](#875)) ([11cd7aa](11cd7aa)), closes [#873](#873) [#874](#874)
* **auth:** pass the bearer token, not the customer-id header, to the M2M JWT verifier ([942070b](942070b)), closes [#873](#873) [#874](#874)
@fraseragain fraseragain mentioned this pull request Nov 6, 2026
2 of 3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

2 participants