Repository navigation
feat(account): self-service + admin account deletion, key export, trial-abuse prevention - #873
Merged
Merged
Conversation
…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
…ys and admin organisation delete 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
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.
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))
|
🎉 This PR is included in version 3.21.0-develop.1 🎉 The release is available on: Your semantic-release bot 📦🚀 |
3 of 4 tasks
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
DELETE /account(self-service) andDELETE /admin/organisation/delete(support-triggered) both run a shareddeleteCustomerAccountpipeline: 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-keysreturns DID private keys (reusing the existing/did/exportmechanism, per-DID so one failure doesn't block the rest) and the two payment-account private keys (new).deletedAccountRecordtable (no FK tocustomer, 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.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-idauth strategy (auth-gaurd.ts→M2MCredsTokenUserInfoFetcher) passes thecustomer-idheader value where the bearer token should go, sojwtVerifyis 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/getand/admin/organisation/updatetoo) 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
npm run test:unit) — 249 passing locally, 1 suite skipped (Postgres-gated)tsc --noEmit)Refs #872