feat(account): unlink game accounts on the shard before an account is deleted (MODULE_API 1.20.0) #47

Merged
whitlocktech merged 1 commits from feature/account-deletion-hook into main 2026-10-29 23:47:35 -05:00
Member

Step 3 of docs/website/ACCOUNT_DELETION.md §6, the UO half of §4.4. Core side: website#232 (merged, 7bd93b8).

Why

shard_account_links is only a mirror. The shard keeps the site user id on the game account and reports it as webId on every mob.login. So the cascade alone would leave the shard attributing the account to a deleted user forever. Deletion has to unlink on the shard (AD10). The shard account and its characters stay (AD8).

What it does (server/utils/accountDeletion.js)

  • onUserDeleting({ userId, deletedBy }):
    1. Puts every linked account into the new shard_pending_unlinks table, before the first call to the shard. Core gives a hook 10 s and one sidecar call can take 12, so an abandoned hook still leaves the rest queued.
    2. Calls the staff unlink's path for each account: uoLinkClient.unlinkAccount({ actor: 'web:account-deletion', account }).
  • What the shard's answer does (AD11):
    • 200: the row and the mirror go, and shard.account.unlink is logged with detail: { account, webUserId, reason: 'account deleted', deletedBy }. The entry has no userId column, because on a retry the user is already gone.
    • 404: the shard has nothing to clear. The row and the mirror go.
    • 403: a protected staff account. The row is marked refused and never retried.
    • Anything else (503, no sidecar, a timeout, a throw): the row stays pending. The mirror row goes with the cascade.
  • Retry: retryPending() runs one pass at a time and tries every pending row. It runs:
    • when the WS opens, after the backfill. That is also the boot path, since onBoot starts the socket and boot never calls the shard.
    • on every live server.hello, which is the plugin reconnecting while the sidecar was already up.
  • A row clears itself when a live mob.login on that account carries no webId, or a different one: the tag is gone, whoever cleared it. A replayed login never clears a row, because one from before the link carries no webId.
  • Admin → Shard:
    • GET /admin/uo-link/config gains pendingUnlinks (the swagger description is updated and the fragment regenerated; no new route).
    • The panel shows a Still linked on the shard section when the list isn't empty. Refused rows tell shard staff to clear the link by hand; pending rows say they're retried on reconnect.

Schema

shard_pending_unlinks (account PK, web_user_id, state, queued_at, attempts, last_attempt_at, last_error). It has no foreign key, on purpose: a row outlives the user it names. It's added to purge.sql.

Also

  • coreApi is ^1.20.0.
  • ci/core-ref.json moves from 655fbf3 (1.10.0) to 7bd93b8 (1.20.0, website#232's merge).

Checked

  • Server: npm test 736/736, check:imports passes, and check:swagger passes after regenerating the fragment.
  • Client: npm run build, and npm test 45/45.
  • The frozen-manifest job, reproduced locally against core 7bd93b8:
    • core alone: route manifest up to date (282 routes)
    • with this module installed: routes.manifest.json is current — 76 routes, all documented
    • core's loader accepted the onUserDeleting registration.
  • New test/accountDeletion.test.js, 13 tests:
    • unlink then mirror then log; accounts are queued before any call
    • pending, refused (never retried, listed for admins), 404, no links, a throwing call
    • a retry keeps going past a failing row; two triggers share one pass
    • server.hello and mob.login act only when live
    • the login clear rule
  • entry.test.js asserts the hook is registered.

Not walked yet: the ServUO + sidecar walk (unlink cleared, then the sidecar stopped, queued, and cleared on return) is §6 step 6.

  • AI-assisted (Claude Code)

🤖 Generated with Claude Code

https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY

Step 3 of `docs/website/ACCOUNT_DELETION.md` §6, the UO half of §4.4. Core side: website#232 (merged, `7bd93b8`). ## Why `shard_account_links` is only a mirror. The shard keeps the site user id on the game account and reports it as `webId` on every `mob.login`. So the cascade alone would leave the shard attributing the account to a deleted user forever. Deletion has to unlink on the shard (AD10). The shard account and its characters stay (AD8). ## What it does (`server/utils/accountDeletion.js`) - **`onUserDeleting({ userId, deletedBy })`**: 1. Puts every linked account into the new **`shard_pending_unlinks`** table, **before** the first call to the shard. Core gives a hook 10 s and one sidecar call can take 12, so an abandoned hook still leaves the rest queued. 2. Calls the staff unlink's path for each account: `uoLinkClient.unlinkAccount({ actor: 'web:account-deletion', account })`. - **What the shard's answer does (AD11):** - **200:** the row and the mirror go, and `shard.account.unlink` is logged with `detail: { account, webUserId, reason: 'account deleted', deletedBy }`. The entry has no `userId` column, because on a retry the user is already gone. - **404:** the shard has nothing to clear. The row and the mirror go. - **403:** a protected staff account. The row is marked `refused` and never retried. - **Anything else** (503, no sidecar, a timeout, a throw): the row stays `pending`. The mirror row goes with the cascade. - **Retry:** `retryPending()` runs one pass at a time and tries every pending row. It runs: - when the WS opens, after the backfill. That is also the boot path, since `onBoot` starts the socket and boot never calls the shard. - on every **live** `server.hello`, which is the plugin reconnecting while the sidecar was already up. - **A row clears itself** when a **live** `mob.login` on that account carries no `webId`, or a different one: the tag is gone, whoever cleared it. A replayed login never clears a row, because one from before the link carries no `webId`. - **Admin → Shard:** - `GET /admin/uo-link/config` gains `pendingUnlinks` (the swagger description is updated and the fragment regenerated; no new route). - The panel shows a *Still linked on the shard* section when the list isn't empty. Refused rows tell shard staff to clear the link by hand; pending rows say they're retried on reconnect. ## Schema `shard_pending_unlinks` (`account` PK, `web_user_id`, `state`, `queued_at`, `attempts`, `last_attempt_at`, `last_error`). It has **no foreign key, on purpose**: a row outlives the user it names. It's added to `purge.sql`. ## Also - `coreApi` is `^1.20.0`. - `ci/core-ref.json` moves from `655fbf3` (1.10.0) to `7bd93b8` (1.20.0, website#232's merge). ## Checked - Server: `npm test` 736/736, `check:imports` passes, and `check:swagger` passes after regenerating the fragment. - Client: `npm run build`, and `npm test` 45/45. - **The frozen-manifest job, reproduced locally** against core `7bd93b8`: - core alone: `route manifest up to date (282 routes)` - with this module installed: `routes.manifest.json is current — 76 routes, all documented` - core's loader accepted the `onUserDeleting` registration. - New `test/accountDeletion.test.js`, 13 tests: - unlink then mirror then log; accounts are queued before any call - pending, refused (never retried, listed for admins), 404, no links, a throwing call - a retry keeps going past a failing row; two triggers share one pass - `server.hello` and `mob.login` act only when live - the login clear rule - `entry.test.js` asserts the hook is registered. **Not walked yet:** the ServUO + sidecar walk (unlink cleared, then the sidecar stopped, queued, and cleared on return) is §6 step 6. - [x] AI-assisted (Claude Code) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
wtclaude added 1 commit 2026-10-29 22:57:16 -05:00
feat(account): unlink game accounts on the shard before an account is deleted (MODULE_API 1.20.0)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 1m0s
PR Checks / client-build (pull_request) Successful in 1m0s
PR Checks / frozen-manifest (pull_request) Successful in 1m20s
582a55dde5
ACCOUNT_DELETION.md §4.4 (AD10, AD11). api.onUserDeleting queues every linked
account in the new shard_pending_unlinks table, then calls the sidecar's
DELETE /link/{account} (actor web:account-deletion) and drops the mirror on 200/404.
A shard that is away leaves the row pending, retried on WS reconnect and on a live
server.hello; a protected account (403) is refused and listed on Admin -> Shard as
still linked on the shard. A live mob.login without the deleted user's webId clears
the row. coreApi ^1.20.0; core-ref pinned to website#232's merge (7bd93b8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
whitlocktech merged commit 148cf0f840 into main 2026-10-29 23:47:35 -05:00
whitlocktech deleted branch feature/account-deletion-hook 2026-10-29 23:47:36 -05:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/Module-uo#47
No description provided.