Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12d973d29c | |||
| 20e4617e6f | |||
| 148cf0f840 | |||
| 582a55dde5 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. **It pointed at `edge` for the length of the Event System window** (org lead, 2026-09-04), and this commit ends that: `api.registerEventActions` exists only from MODULE_API 1.10.0, so under the previous `main` pin `register()` threw and the module did not load at all — the job would have been red by construction for eight phases and would have proved nothing while a real regression hid behind it. The Phase 16b cutover put 1.10.0 on `main`, so the pin comes home, and this is the same move that turns the Integration kit green again. **routes.manifest.json needed NO regeneration**: the job's own steps were run against this exact ref and answered `routes.manifest.json is current — 73 routes, all documented`, so the \"commit both together\" instruction above had nothing to pair with this time.",
|
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. **It pointed at `edge` for the length of the Event System window** (org lead, 2026-09-04), and this commit ends that: `api.registerEventActions` exists only from MODULE_API 1.10.0, so under the previous `main` pin `register()` threw and the module did not load at all — the job would have been red by construction for eight phases and would have proved nothing while a real regression hid behind it. The Phase 16b cutover put 1.10.0 on `main`, so the pin comes home, and this is the same move that turns the Integration kit green again. **routes.manifest.json needed NO regeneration**: the job's own steps were run against this exact ref and answered `routes.manifest.json is current — 73 routes, all documented`, so the \"commit both together\" instruction above had nothing to pair with this time. **2026-10-30, account deletion (ACCOUNT_DELETION.md §4.4):** this module registers onUserDeleting (MODULE_API 1.20.0), so coreApi is ^1.20.0 and the pin moves past 1.11.0-1.19.0 to `main` at the sha website#232 merged as. A 1.19.0 core has no api.onUserDeleting, so register() would throw under the old pin.",
|
||||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||||
"ref": "655fbf3f69a6a1fd650ecbc81afd6cf9c2ad9f66",
|
"ref": "7bd93b8bb0da734a642587f256d10c523ca39bea",
|
||||||
"refName": "main @ MODULE_API 1.10.0, the Event System cutover (website#199)"
|
"refName": "main @ MODULE_API 1.20.0, account deletion (website#232)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,39 @@ function StatusPanel({ config }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Unlinks a deleted website account left behind ───────────────────────────
|
||||||
|
//
|
||||||
|
// ACCOUNT_DELETION.md §4.4. Deleting a website account unlinks its game accounts
|
||||||
|
// on the shard first; what the shard could not take is listed here until it
|
||||||
|
// clears. `pending` is retried by itself when the bridge reconnects. `refused` is
|
||||||
|
// a protected staff account the shard will not unlink over the bridge, so the
|
||||||
|
// shard's staff clear its WebsiteUserId tag by hand. A row goes on its own once a
|
||||||
|
// login on that account no longer carries the deleted user's id.
|
||||||
|
function PendingUnlinks({ rows }) {
|
||||||
|
if (!rows || rows.length === 0) return null
|
||||||
|
return (
|
||||||
|
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Still linked on the shard</h3>
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
|
||||||
|
Game accounts whose website account was deleted, but whose link the shard has not cleared yet.
|
||||||
|
</p>
|
||||||
|
<ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<li key={r.account} className="sans" style={{ fontSize: '0.85rem', color: 'var(--ink)' }}>
|
||||||
|
<strong>{r.account}</strong>
|
||||||
|
<span className="dim"> · deleted user #{r.webUserId} · {new Date(r.queuedAt).toLocaleString()}</span>
|
||||||
|
<div className="dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
|
||||||
|
{r.state === 'refused'
|
||||||
|
? 'Protected staff account: the shard will not unlink it over the bridge. Clear its link on the shard by hand.'
|
||||||
|
: `Waiting for the shard; tried ${r.attempts} time${r.attempts === 1 ? '' : 's'}. It tries again when the bridge reconnects.`}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Game-account signup ─────────────────────────────────────────────────────
|
// ── Game-account signup ─────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// This field lived in core's Site Settings until slice 3 of the extraction. It
|
// This field lived in core's Site Settings until slice 3 of the extraction. It
|
||||||
@@ -285,6 +318,8 @@ export default function ShardAdmin() {
|
|||||||
|
|
||||||
<StatusPanel config={config} />
|
<StatusPanel config={config} />
|
||||||
|
|
||||||
|
<PendingUnlinks rows={config.pendingUnlinks} />
|
||||||
|
|
||||||
<Toggle checked={enabled} onChange={setEnabled} label="Enable the shard integration" />
|
<Toggle checked={enabled} onChange={setEnabled} label="Enable the shard integration" />
|
||||||
|
|
||||||
<label style={{ display: 'block' }}>
|
<label style={{ display: 'block' }}>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "uo",
|
"id": "uo",
|
||||||
"name": "Ultima Online",
|
"name": "Ultima Online",
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"coreApi": "^1.10.0",
|
"coreApi": "^1.20.0",
|
||||||
"server": "server/index.js",
|
"server": "server/index.js",
|
||||||
"client": { "entry": "client/dist/entry.js" },
|
"client": { "entry": "client/dist/entry.js" },
|
||||||
"schema": "server/db/schema.sql",
|
"schema": "server/db/schema.sql",
|
||||||
|
|||||||
@@ -27,6 +27,10 @@
|
|||||||
-- marker, and deleting it would re-arm a protocol bump against tables this
|
-- marker, and deleting it would re-arm a protocol bump against tables this
|
||||||
-- file has just dropped.
|
-- file has just dropped.
|
||||||
|
|
||||||
|
-- Account deletion's queue (MODULE_API 1.20.0), the newest table. No foreign key:
|
||||||
|
-- its rows outlive the users they name, by design.
|
||||||
|
DROP TABLE IF EXISTS `shard_pending_unlinks`;
|
||||||
|
|
||||||
-- The Asset Bridge's three (phase 3). No foreign keys of their own, so they lead:
|
-- The Asset Bridge's three (phase 3). No foreign keys of their own, so they lead:
|
||||||
-- `shard_creature_bodies.slug` mirrors an atlas slug and `shard_assets.body` a body
|
-- `shard_creature_bodies.slug` mirrors an atlas slug and `shard_assets.body` a body
|
||||||
-- id, but neither is declared as a constraint — the atlas tables are rebuilt from
|
-- id, but neither is declared as a constraint — the atlas tables are rebuilt from
|
||||||
|
|||||||
@@ -974,3 +974,27 @@ INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_8_migrated'
|
|||||||
-- applied to an empty database. That is the same blind spot the protocol-pin block
|
-- applied to an empty database. That is the same blind spot the protocol-pin block
|
||||||
-- above records, two phases running.
|
-- above records, two phases running.
|
||||||
ALTER TABLE shard_spawn_points ADD COLUMN IF NOT EXISTS unique_id VARCHAR(64) NULL;
|
ALTER TABLE shard_spawn_points ADD COLUMN IF NOT EXISTS unique_id VARCHAR(64) NULL;
|
||||||
|
|
||||||
|
-- Game-account unlinks a website account's deletion could not finish
|
||||||
|
-- (MODULE_API 1.20.0; ACCOUNT_DELETION.md §4.4, AD11). `utils/accountDeletion.js`
|
||||||
|
-- queues every linked account BEFORE it asks the shard, and drops the row when the
|
||||||
|
-- shard answers 200 or 404.
|
||||||
|
-- pending the shard was away (503, no sidecar, a timeout): retried when the
|
||||||
|
-- bridge reconnects and on a live server.hello
|
||||||
|
-- refused a protected staff account (403): never retried; Admin → Shard lists
|
||||||
|
-- it as still linked on the shard, for the shard's staff to clear
|
||||||
|
-- Either goes once a live mob.login on the account carries no webId, or another.
|
||||||
|
--
|
||||||
|
-- **No foreign key, deliberately.** `web_user_id` names a user who is gone by the
|
||||||
|
-- time the row matters; it is the id the shard still reports, kept so a login can
|
||||||
|
-- show the tag has gone.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_pending_unlinks (
|
||||||
|
account VARCHAR(120) NOT NULL PRIMARY KEY,
|
||||||
|
web_user_id INT NOT NULL,
|
||||||
|
state VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||||
|
queued_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
|
last_attempt_at DATETIME NULL,
|
||||||
|
last_error VARCHAR(255) NULL,
|
||||||
|
INDEX idx_shard_pending_unlinks_state (state)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const engagementSeeds = require('./config/engagementSeeds')
|
|||||||
const teamProvider = require('./model/teamProvider/teamProvider.model')
|
const teamProvider = require('./model/teamProvider/teamProvider.model')
|
||||||
const guildCommand = require('./commands/guild.command')
|
const guildCommand = require('./commands/guild.command')
|
||||||
const boot = require('./boot')
|
const boot = require('./boot')
|
||||||
|
const accountDeletion = require('./utils/accountDeletion')
|
||||||
/* eslint-enable global-require */
|
/* eslint-enable global-require */
|
||||||
|
|
||||||
const log = core.logger()
|
const log = core.logger()
|
||||||
@@ -184,6 +185,13 @@ const engagementSeeds = require('./config/engagementSeeds')
|
|||||||
api.onBoot(boot.onBoot)
|
api.onBoot(boot.onBoot)
|
||||||
api.onShutdown(boot.onShutdown)
|
api.onShutdown(boot.onShutdown)
|
||||||
|
|
||||||
|
// Account deletion (MODULE_API 1.20.0; ACCOUNT_DELETION.md §4.4, AD10, AD11).
|
||||||
|
// Core asks before the users row goes: each linked game account is unlinked on
|
||||||
|
// the shard through the staff unlink's path, so the shard stops reporting the
|
||||||
|
// deleted user's id. It cannot stop the deletion; what the shard could not take
|
||||||
|
// now waits in `shard_pending_unlinks` and is retried when the bridge is back.
|
||||||
|
api.onUserDeleting(accountDeletion.onUserDeleting)
|
||||||
|
|
||||||
log.info('registered', {
|
log.info('registered', {
|
||||||
version: require('../module.json').version,
|
version: require('../module.json').version,
|
||||||
routes: 'public:/shard,/atlas admin:/shard,/uo-link player:/shard',
|
routes: 'public:/shard,/atlas admin:/shard,/uo-link player:/shard',
|
||||||
|
|||||||
50
server/model/shardPendingUnlinks/shardPendingUnlinks.db.js
Normal file
50
server/model/shardPendingUnlinks/shardPendingUnlinks.db.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// Game-account unlinks that a website account's deletion could not finish
|
||||||
|
// (ACCOUNT_DELETION.md §4.4, AD11). See utils/accountDeletion.js for who writes
|
||||||
|
// and who clears these rows.
|
||||||
|
|
||||||
|
const { query } = require('../../core')
|
||||||
|
|
||||||
|
const COLS = 'account, web_user_id, state, queued_at, attempts, last_attempt_at, last_error'
|
||||||
|
|
||||||
|
// Queue an account before the first call to the shard, so a hook core abandons
|
||||||
|
// at its time limit still leaves the account here. A re-queue (the same account
|
||||||
|
// deleted twice is impossible, but a test or a retry is not) starts it over.
|
||||||
|
const queue = ({ account, webUserId }) =>
|
||||||
|
query(
|
||||||
|
`INSERT INTO shard_pending_unlinks (account, web_user_id, state)
|
||||||
|
VALUES (?, ?, 'pending')
|
||||||
|
ON DUPLICATE KEY UPDATE web_user_id = VALUES(web_user_id), state = 'pending',
|
||||||
|
attempts = 0, last_attempt_at = NULL, last_error = NULL`,
|
||||||
|
[account, webUserId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const remove = (account) => query('DELETE FROM shard_pending_unlinks WHERE account = ?', [account])
|
||||||
|
|
||||||
|
// One attempt that did not finish. `state` is `pending` (try again) or `refused`
|
||||||
|
// (the shard said no for good: a protected staff account).
|
||||||
|
const recordAttempt = ({ account, state, error }) =>
|
||||||
|
query(
|
||||||
|
`UPDATE shard_pending_unlinks
|
||||||
|
SET state = ?, attempts = attempts + 1, last_attempt_at = CURRENT_TIMESTAMP, last_error = ?
|
||||||
|
WHERE account = ?`,
|
||||||
|
[state, error ? String(error).slice(0, 255) : null, account],
|
||||||
|
)
|
||||||
|
|
||||||
|
const listPending = () =>
|
||||||
|
query(`SELECT ${COLS} FROM shard_pending_unlinks WHERE state = 'pending' ORDER BY queued_at ASC`)
|
||||||
|
|
||||||
|
const listAll = () => query(`SELECT ${COLS} FROM shard_pending_unlinks ORDER BY queued_at ASC`)
|
||||||
|
|
||||||
|
// The shard just reported this account logging in with `webId` (or none). When
|
||||||
|
// that is no longer the deleted user, the tag is gone and the row has nothing
|
||||||
|
// left to wait for. Returns the number of rows dropped.
|
||||||
|
async function clearIfNotAttributed(account, webId) {
|
||||||
|
const result = await query(
|
||||||
|
`DELETE FROM shard_pending_unlinks
|
||||||
|
WHERE account = ? AND (? IS NULL OR web_user_id <> ?)`,
|
||||||
|
[account, webId, webId],
|
||||||
|
)
|
||||||
|
return Number(result && result.affectedRows) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { queue, remove, recordAttempt, listPending, listAll, clearIfNotAttributed }
|
||||||
@@ -11,11 +11,15 @@ const uoLinkClient = require('../../utils/uoLinkClient')
|
|||||||
const uoLinkSocket = require('../../utils/uoLinkSocket')
|
const uoLinkSocket = require('../../utils/uoLinkSocket')
|
||||||
const shardBroadcast = require('../../utils/shardBroadcast')
|
const shardBroadcast = require('../../utils/shardBroadcast')
|
||||||
const gameSignup = require('../../utils/gameSignup')
|
const gameSignup = require('../../utils/gameSignup')
|
||||||
|
const accountDeletion = require('../../utils/accountDeletion')
|
||||||
const { activity } = require('../../core')
|
const { activity } = require('../../core')
|
||||||
|
|
||||||
const log = require('../../core').logger('admin-uolink')
|
const log = require('../../core').logger('admin-uolink')
|
||||||
|
|
||||||
// Assemble the masked config + live health + ingestion stats for the panel.
|
// Assemble the masked config + live health + ingestion stats for the panel, and
|
||||||
|
// the game accounts a website account's deletion left linked on the shard
|
||||||
|
// (ACCOUNT_DELETION.md §4.4): `pending` waits for the shard, `refused` is a
|
||||||
|
// protected staff account the shard's staff must clear by hand.
|
||||||
async function buildStatus() {
|
async function buildStatus() {
|
||||||
const config = await uoLinkConfig.getSafe()
|
const config = await uoLinkConfig.getSafe()
|
||||||
const health = await uoLinkClient.health()
|
const health = await uoLinkClient.health()
|
||||||
@@ -24,6 +28,7 @@ async function buildStatus() {
|
|||||||
health: health.ok ? health.data : { ok: false, error: health.error || `status ${health.status}` },
|
health: health.ok ? health.data : { ok: false, error: health.error || `status ${health.status}` },
|
||||||
ingest: uoLinkSocket.getState(),
|
ingest: uoLinkSocket.getState(),
|
||||||
sse: shardBroadcast.stats(),
|
sse: shardBroadcast.stats(),
|
||||||
|
pendingUnlinks: await accountDeletion.listForAdmin(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ uoLinkRouter.get(
|
|||||||
// #swagger.tags = ['Admin · Shard']
|
// #swagger.tags = ['Admin · Shard']
|
||||||
// #swagger.summary = 'Get uo-link config + live status + ingestion stats (admin only)'
|
// #swagger.summary = 'Get uo-link config + live status + ingestion stats (admin only)'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
/* #swagger.responses[200] = { description: 'Masked config, health and ingestion stats', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
// #swagger.description = 'pendingUnlinks lists the game accounts a website account deletion left linked on the shard: state pending (the shard was away; retried when the bridge reconnects) or refused (a protected staff account; shard staff clear it by hand). A row goes once the shard reports a login on the account without the deleted user id.'
|
||||||
|
/* #swagger.responses[200] = { description: 'Masked config, health, ingestion stats and pendingUnlinks', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
adminOnly,
|
adminOnly,
|
||||||
uoLink.getConfig,
|
uoLink.getConfig,
|
||||||
|
|||||||
@@ -149,6 +149,8 @@ function fakeApi() {
|
|||||||
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
|
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
|
||||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||||
|
// MODULE_API 1.20.0 (account deletion, ACCOUNT_DELETION.md §4.4).
|
||||||
|
onUserDeleting(fn) { once('onUserDeleting'); record.hooks.onUserDeleting = fn },
|
||||||
}
|
}
|
||||||
api.record = record
|
api.record = record
|
||||||
return api
|
return api
|
||||||
|
|||||||
312
server/test/accountDeletion.test.js
Normal file
312
server/test/accountDeletion.test.js
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
// ── A website account is being deleted (MODULE_API 1.20.0) ──────────────────
|
||||||
|
//
|
||||||
|
// ACCOUNT_DELETION.md §4.4, AD10 and AD11. What these hold:
|
||||||
|
//
|
||||||
|
// • the shard is asked to unlink every game account, through the same sidecar
|
||||||
|
// call the staff unlink makes, and the mirror goes only when it said yes;
|
||||||
|
// • every account is queued BEFORE the first call, so a hook core abandons at
|
||||||
|
// its ten-second limit still leaves the rest in the queue;
|
||||||
|
// • a shard that is away leaves the row pending; a protected account (403)
|
||||||
|
// leaves it refused and is never asked again;
|
||||||
|
// • a live login without the deleted user's id clears the row, and a replayed
|
||||||
|
// one never does.
|
||||||
|
|
||||||
|
const { test, afterEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const { ctx } = require('./_setup')
|
||||||
|
const accountDeletion = require('../utils/accountDeletion')
|
||||||
|
const shardIngest = require('../utils/shardIngest')
|
||||||
|
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||||
|
const pendingDb = require('../model/shardPendingUnlinks/shardPendingUnlinks.db')
|
||||||
|
const uoLinkClient = require('../utils/uoLinkClient')
|
||||||
|
|
||||||
|
const originals = {
|
||||||
|
listForUser: shardLinks.listForUser,
|
||||||
|
removeByAccount: shardLinks.removeByAccount,
|
||||||
|
unlinkAccount: uoLinkClient.unlinkAccount,
|
||||||
|
pending: { ...pendingDb },
|
||||||
|
activityLog: ctx.activity.log,
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
shardLinks.listForUser = originals.listForUser
|
||||||
|
shardLinks.removeByAccount = originals.removeByAccount
|
||||||
|
uoLinkClient.unlinkAccount = originals.unlinkAccount
|
||||||
|
Object.assign(pendingDb, originals.pending)
|
||||||
|
ctx.activity.log = originals.activityLog
|
||||||
|
accountDeletion._reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** An in-memory `shard_pending_unlinks`, and a journal of everything asked of it. */
|
||||||
|
function fakeQueue(rows = []) {
|
||||||
|
const table = new Map(rows.map((r) => [r.account, { attempts: 0, state: 'pending', ...r }]))
|
||||||
|
const journal = []
|
||||||
|
pendingDb.queue = async ({ account, webUserId }) => {
|
||||||
|
journal.push(['queue', account])
|
||||||
|
table.set(account, { account, web_user_id: webUserId, state: 'pending', attempts: 0 })
|
||||||
|
}
|
||||||
|
pendingDb.remove = async (account) => {
|
||||||
|
journal.push(['remove', account])
|
||||||
|
table.delete(account)
|
||||||
|
}
|
||||||
|
pendingDb.recordAttempt = async ({ account, state, error }) => {
|
||||||
|
journal.push(['attempt', account, state])
|
||||||
|
const row = table.get(account)
|
||||||
|
if (row) Object.assign(row, { state, attempts: row.attempts + 1, last_error: error })
|
||||||
|
}
|
||||||
|
pendingDb.listPending = async () => [...table.values()].filter((r) => r.state === 'pending')
|
||||||
|
pendingDb.listAll = async () => [...table.values()]
|
||||||
|
pendingDb.clearIfNotAttributed = async (account, webId) => {
|
||||||
|
const row = table.get(account)
|
||||||
|
if (!row || (webId !== null && row.web_user_id === webId)) return 0
|
||||||
|
table.delete(account)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return { table, journal }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sidecar, answering per account from a script. */
|
||||||
|
function fakeShard(replies) {
|
||||||
|
const asked = []
|
||||||
|
uoLinkClient.unlinkAccount = async ({ actor, account }) => {
|
||||||
|
asked.push({ actor, account })
|
||||||
|
return replies[account]
|
||||||
|
}
|
||||||
|
return asked
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackMirror() {
|
||||||
|
const removed = []
|
||||||
|
shardLinks.removeByAccount = async (account) => { removed.push(account) }
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackActivity() {
|
||||||
|
const entries = []
|
||||||
|
ctx.activity.log = async (entry) => { entries.push(entry) }
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
const OK = { ok: true, status: 200, data: { ok: true } }
|
||||||
|
const ABSENT = { ok: false, status: 404 }
|
||||||
|
const PROTECTED = { ok: false, status: 403, error: 'protected' }
|
||||||
|
const AWAY = { ok: false, status: 503, error: 'plugin not connected' }
|
||||||
|
|
||||||
|
test('every game account is unlinked on the shard, then dropped from the mirror and logged', async () => {
|
||||||
|
shardLinks.listForUser = async () => [{ account: 'thorne' }, { account: 'thorne2' }]
|
||||||
|
const { table } = fakeQueue()
|
||||||
|
const asked = fakeShard({ thorne: OK, thorne2: OK })
|
||||||
|
const mirror = trackMirror()
|
||||||
|
const entries = trackActivity()
|
||||||
|
|
||||||
|
await accountDeletion.onUserDeleting({ userId: 7, deletedBy: 'self' })
|
||||||
|
|
||||||
|
// The staff unlink's sidecar call, with an actor that says why.
|
||||||
|
assert.deepEqual(asked, [
|
||||||
|
{ actor: 'web:account-deletion', account: 'thorne' },
|
||||||
|
{ actor: 'web:account-deletion', account: 'thorne2' },
|
||||||
|
])
|
||||||
|
assert.deepEqual(mirror, ['thorne', 'thorne2'])
|
||||||
|
assert.equal(table.size, 0, 'nothing left queued')
|
||||||
|
|
||||||
|
// No `userId` column: on a retry the user is already gone.
|
||||||
|
assert.deepEqual(entries, [
|
||||||
|
{ action: 'shard.account.unlink', detail: { account: 'thorne', webUserId: 7, reason: 'account deleted', deletedBy: 'self' } },
|
||||||
|
{ action: 'shard.account.unlink', detail: { account: 'thorne2', webUserId: 7, reason: 'account deleted', deletedBy: 'self' } },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every account is queued before the shard is asked anything', async () => {
|
||||||
|
// A hook core abandons at ten seconds keeps running, but core deletes the user
|
||||||
|
// meanwhile; one sidecar call can take twelve. Queued first, nothing is lost.
|
||||||
|
shardLinks.listForUser = async () => [{ account: 'a' }, { account: 'b' }]
|
||||||
|
const { journal } = fakeQueue()
|
||||||
|
fakeShard({ a: OK, b: OK })
|
||||||
|
trackMirror()
|
||||||
|
trackActivity()
|
||||||
|
|
||||||
|
await accountDeletion.onUserDeleting({ userId: 7, deletedBy: 'staff' })
|
||||||
|
|
||||||
|
assert.deepEqual(journal.slice(0, 2), [['queue', 'a'], ['queue', 'b']])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a shard that is away leaves the account pending, and the mirror to the cascade', async () => {
|
||||||
|
shardLinks.listForUser = async () => [{ account: 'thorne' }]
|
||||||
|
const { table } = fakeQueue()
|
||||||
|
fakeShard({ thorne: AWAY })
|
||||||
|
const mirror = trackMirror()
|
||||||
|
const entries = trackActivity()
|
||||||
|
|
||||||
|
await accountDeletion.onUserDeleting({ userId: 7, deletedBy: 'self' })
|
||||||
|
|
||||||
|
assert.equal(table.get('thorne').state, 'pending')
|
||||||
|
assert.equal(table.get('thorne').web_user_id, 7)
|
||||||
|
assert.equal(table.get('thorne').last_error, 'plugin not connected')
|
||||||
|
assert.deepEqual(mirror, [])
|
||||||
|
assert.deepEqual(entries, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a protected staff account is refused, and a retry never asks again', async () => {
|
||||||
|
shardLinks.listForUser = async () => [{ account: 'gm' }]
|
||||||
|
const { table } = fakeQueue()
|
||||||
|
const asked = fakeShard({ gm: PROTECTED })
|
||||||
|
trackMirror()
|
||||||
|
trackActivity()
|
||||||
|
|
||||||
|
await accountDeletion.onUserDeleting({ userId: 7, deletedBy: 'self' })
|
||||||
|
assert.equal(table.get('gm').state, 'refused')
|
||||||
|
|
||||||
|
await accountDeletion.retryPending()
|
||||||
|
assert.equal(asked.length, 1, 'the retry skipped the refused row')
|
||||||
|
|
||||||
|
// Admin → Shard sees it.
|
||||||
|
const listed = await accountDeletion.listForAdmin()
|
||||||
|
assert.equal(listed.length, 1)
|
||||||
|
assert.equal(listed[0].account, 'gm')
|
||||||
|
assert.equal(listed[0].state, 'refused')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a 404 means the shard has nothing to clear: the row and the mirror go, with no unlink entry', async () => {
|
||||||
|
shardLinks.listForUser = async () => [{ account: 'thorne' }]
|
||||||
|
const { table } = fakeQueue()
|
||||||
|
fakeShard({ thorne: ABSENT })
|
||||||
|
const mirror = trackMirror()
|
||||||
|
const entries = trackActivity()
|
||||||
|
|
||||||
|
await accountDeletion.onUserDeleting({ userId: 7, deletedBy: 'self' })
|
||||||
|
|
||||||
|
assert.equal(table.size, 0)
|
||||||
|
assert.deepEqual(mirror, ['thorne'])
|
||||||
|
assert.deepEqual(entries, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an account with no game links asks the shard nothing', async () => {
|
||||||
|
shardLinks.listForUser = async () => []
|
||||||
|
const { journal } = fakeQueue()
|
||||||
|
const asked = fakeShard({})
|
||||||
|
|
||||||
|
await accountDeletion.onUserDeleting({ userId: 7, deletedBy: 'self' })
|
||||||
|
|
||||||
|
assert.deepEqual(journal, [])
|
||||||
|
assert.deepEqual(asked, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a call that throws leaves the account queued and the hook still resolves', async () => {
|
||||||
|
shardLinks.listForUser = async () => [{ account: 'a' }, { account: 'b' }]
|
||||||
|
const { table } = fakeQueue()
|
||||||
|
uoLinkClient.unlinkAccount = async ({ account }) => {
|
||||||
|
if (account === 'a') throw new Error('socket hang up')
|
||||||
|
return OK
|
||||||
|
}
|
||||||
|
trackMirror()
|
||||||
|
trackActivity()
|
||||||
|
|
||||||
|
await accountDeletion.onUserDeleting({ userId: 7, deletedBy: 'self' })
|
||||||
|
|
||||||
|
assert.deepEqual([...table.keys()], ['a'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a retry finishes what the shard can take and keeps the rest', async () => {
|
||||||
|
const { table } = fakeQueue([
|
||||||
|
{ account: 'a', web_user_id: 7 },
|
||||||
|
{ account: 'b', web_user_id: 7 },
|
||||||
|
])
|
||||||
|
// One account failing does not hold up the other.
|
||||||
|
const asked = fakeShard({ a: AWAY, b: OK })
|
||||||
|
trackMirror()
|
||||||
|
trackActivity()
|
||||||
|
|
||||||
|
await accountDeletion.retryPending()
|
||||||
|
|
||||||
|
assert.deepEqual(asked.map((x) => x.account), ['a', 'b'])
|
||||||
|
assert.deepEqual([...table.keys()], ['a'])
|
||||||
|
assert.equal(table.get('a').attempts, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('two triggers at once share one retry pass', async () => {
|
||||||
|
fakeQueue([{ account: 'a', web_user_id: 7 }])
|
||||||
|
let calls = 0
|
||||||
|
uoLinkClient.unlinkAccount = async () => {
|
||||||
|
calls += 1
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
|
return OK
|
||||||
|
}
|
||||||
|
trackMirror()
|
||||||
|
trackActivity()
|
||||||
|
|
||||||
|
await Promise.all([accountDeletion.retryPending(), accountDeletion.retryPending()])
|
||||||
|
assert.equal(calls, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a retry that cannot read the queue resolves anyway', async () => {
|
||||||
|
fakeQueue()
|
||||||
|
pendingDb.listPending = async () => { throw new Error('pool closed') }
|
||||||
|
await accountDeletion.retryPending()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Through the ingest dispatcher ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function ingestDeps(pendingUnlinks) {
|
||||||
|
const noop = async () => {}
|
||||||
|
return {
|
||||||
|
shardEvents: { append: noop },
|
||||||
|
shardState: { upsertOnline: noop, clearOnline: noop, setOffline: noop },
|
||||||
|
shardLinks: {},
|
||||||
|
shardMarket: {},
|
||||||
|
uoLinkConfig: { recordStatus: noop },
|
||||||
|
settings: { getInstanceName: async () => 'Rig' },
|
||||||
|
broadcast: () => {},
|
||||||
|
pushDispatch: () => {},
|
||||||
|
engagement: () => {},
|
||||||
|
eventsReconcile: () => {},
|
||||||
|
log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
|
||||||
|
pendingUnlinks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function spyUnlinks() {
|
||||||
|
const calls = { retry: 0, seen: [] }
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
retryPending: () => { calls.retry += 1; return Promise.resolve() },
|
||||||
|
seenLogin: async (arg) => { calls.seen.push(arg) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a live server.hello retries the queue; a replayed one does not', async () => {
|
||||||
|
const spy = spyUnlinks()
|
||||||
|
await shardIngest.ingest({ kind: 'server.hello', bootId: 'b1', t: 1 }, ingestDeps(spy))
|
||||||
|
assert.equal(spy.calls.retry, 1)
|
||||||
|
|
||||||
|
await shardIngest.ingest({ kind: 'server.hello', bootId: 'b1', t: 2 }, { ...ingestDeps(spy), fromBackfill: true })
|
||||||
|
assert.equal(spy.calls.retry, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a live login is checked against the queue; a replayed one never is', async () => {
|
||||||
|
const spy = spyUnlinks()
|
||||||
|
const login = { kind: 'mob.login', who: { serial: 1, name: 'Thorne', acct: 'thorne' }, t: 1 }
|
||||||
|
|
||||||
|
await shardIngest.ingest(login, ingestDeps(spy))
|
||||||
|
assert.deepEqual(spy.calls.seen, [{ account: 'thorne', webId: undefined }])
|
||||||
|
|
||||||
|
await shardIngest.ingest({ ...login, webId: 7 }, { ...ingestDeps(spy), fromBackfill: true })
|
||||||
|
assert.equal(spy.calls.seen.length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a login without the deleted user’s id clears the row; one still carrying it does not', async () => {
|
||||||
|
const { table } = fakeQueue([
|
||||||
|
{ account: 'thorne', web_user_id: 7, state: 'refused' },
|
||||||
|
{ account: 'gm', web_user_id: 9, state: 'refused' },
|
||||||
|
])
|
||||||
|
|
||||||
|
await accountDeletion.seenLogin({ account: 'gm', webId: 9 })
|
||||||
|
await accountDeletion.seenLogin({ account: 'gm', webId: '9' })
|
||||||
|
assert.ok(table.has('gm'), 'still tagged with the deleted user')
|
||||||
|
|
||||||
|
await accountDeletion.seenLogin({ account: 'thorne', webId: undefined })
|
||||||
|
assert.ok(!table.has('thorne'), 'the tag is gone, whoever cleared it')
|
||||||
|
|
||||||
|
await accountDeletion.seenLogin({ account: 'gm', webId: 12 })
|
||||||
|
assert.ok(!table.has('gm'), 'relinked to someone else')
|
||||||
|
})
|
||||||
@@ -131,6 +131,8 @@ test('registers exactly what module.json declares', () => {
|
|||||||
assert.ok(api.record.streams.length > 0)
|
assert.ok(api.record.streams.length > 0)
|
||||||
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
|
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
|
||||||
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
|
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
|
||||||
|
// MODULE_API 1.20.0: a deleted account's game links are unlinked on the shard.
|
||||||
|
assert.strictEqual(api.record.hooks.onUserDeleting, require('../utils/accountDeletion').onUserDeleting)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('every registered stream is namespaced or grandfathered', () => {
|
test('every registered stream is namespaced or grandfathered', () => {
|
||||||
|
|||||||
154
server/utils/accountDeletion.js
Normal file
154
server/utils/accountDeletion.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
// ── A website account is being deleted (MODULE_API 1.20.0) ──────────────────
|
||||||
|
//
|
||||||
|
// ACCOUNT_DELETION.md §4.4. Core asks before the users row goes (AD10), and the
|
||||||
|
// row's cascade would drop `shard_account_links` anyway — but that table 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 a cascade alone would leave the shard
|
||||||
|
// attributing the account to a user who no longer exists. The unlink here is the
|
||||||
|
// staff unlink's path (usersShard.controller.js): the sidecar's
|
||||||
|
// `DELETE /link/{account}` first, the mirror after. The shard account and its
|
||||||
|
// characters stay (AD8).
|
||||||
|
//
|
||||||
|
// **An unlink that cannot finish never holds up the deletion** (AD11):
|
||||||
|
//
|
||||||
|
// • every account is queued in `shard_pending_unlinks` BEFORE the first call,
|
||||||
|
// so a hook core abandons at its ten-second limit still leaves the rest
|
||||||
|
// queued (one sidecar call may take twelve);
|
||||||
|
// • a shard that is away (503, no sidecar, a timeout) leaves the row
|
||||||
|
// `pending`, retried whenever the bridge comes back — the WS reconnecting,
|
||||||
|
// which is also how a boot gets here, and a live `server.hello`;
|
||||||
|
// • a protected staff account, which the shard refuses by design (403), is
|
||||||
|
// `refused`: retrying will not help, so Admin → Shard lists it as still
|
||||||
|
// linked on the shard for the shard's staff to clear by hand.
|
||||||
|
//
|
||||||
|
// Either kind of row goes once the shard reports a live login on that account
|
||||||
|
// with no `webId`, or a different one: the tag is gone, whoever cleared it.
|
||||||
|
|
||||||
|
const core = require('../core')
|
||||||
|
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||||
|
const pendingDb = require('../model/shardPendingUnlinks/shardPendingUnlinks.db')
|
||||||
|
const uoLinkClient = require('./uoLinkClient')
|
||||||
|
|
||||||
|
const log = core.logger('account-deletion')
|
||||||
|
|
||||||
|
// What the shard's audit records as the one who asked.
|
||||||
|
const ACTOR = 'web:account-deletion'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the shard to unlink one queued account, and record what it said.
|
||||||
|
*
|
||||||
|
* 200 and 404 both finish it: 404 means the shard has nothing to clear. Never
|
||||||
|
* throws for a shard answer; a database error does throw, to the caller's catch.
|
||||||
|
*
|
||||||
|
* @returns {Promise<'unlinked'|'absent'|'refused'|'pending'>}
|
||||||
|
*/
|
||||||
|
async function attempt({ account, webUserId, reason }) {
|
||||||
|
const result = await uoLinkClient.unlinkAccount({ actor: ACTOR, account })
|
||||||
|
if (result.ok || result.status === 404) {
|
||||||
|
await shardLinks.removeByAccount(account)
|
||||||
|
await pendingDb.remove(account)
|
||||||
|
if (!result.ok) return 'absent'
|
||||||
|
// No `userId` on the entry: on a retry the account is already gone, and the
|
||||||
|
// activity log's user_id is a key into `users`. The id is in the detail.
|
||||||
|
await core.activity.log({
|
||||||
|
action: 'shard.account.unlink',
|
||||||
|
detail: { account, webUserId, reason: 'account deleted', ...(reason ? { deletedBy: reason } : {}) },
|
||||||
|
})
|
||||||
|
return 'unlinked'
|
||||||
|
}
|
||||||
|
const error = result.error || `status ${result.status}`
|
||||||
|
if (result.status === 403) {
|
||||||
|
await pendingDb.recordAttempt({ account, state: 'refused', error })
|
||||||
|
log.warn('the shard refused to unlink a deleted account’s game account (protected); listed for shard staff', { account, webUserId })
|
||||||
|
return 'refused'
|
||||||
|
}
|
||||||
|
await pendingDb.recordAttempt({ account, state: 'pending', error })
|
||||||
|
return 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core's `api.onUserDeleting`. Unlinks every game account this website account
|
||||||
|
* holds, through the staff unlink's path. Cannot stop the deletion; whatever it
|
||||||
|
* does not finish stays queued.
|
||||||
|
*/
|
||||||
|
async function onUserDeleting({ userId, deletedBy }) {
|
||||||
|
const links = await shardLinks.listForUser(userId)
|
||||||
|
if (!links.length) return
|
||||||
|
|
||||||
|
for (const { account } of links) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await pendingDb.queue({ account, webUserId: userId })
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcomes = []
|
||||||
|
for (const { account } of links) {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
outcomes.push({ account, outcome: await attempt({ account, webUserId: userId, reason: deletedBy }) })
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('an unlink before an account deletion failed; it stays queued', { account, error: err.message })
|
||||||
|
outcomes.push({ account, outcome: 'pending' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info('game accounts unlinked before an account was deleted', { userId, deletedBy, outcomes })
|
||||||
|
}
|
||||||
|
|
||||||
|
let inFlight = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try every `pending` row once more. One pass at a time: a reconnect and a
|
||||||
|
* `server.hello` arriving together share the pass already running. Every row is
|
||||||
|
* tried, so one account the shard keeps failing on does not hold up the rest.
|
||||||
|
* Never rejects, so a caller may leave it un-awaited.
|
||||||
|
*/
|
||||||
|
function retryPending() {
|
||||||
|
if (inFlight) return inFlight
|
||||||
|
inFlight = (async () => {
|
||||||
|
try {
|
||||||
|
const rows = await pendingDb.listPending()
|
||||||
|
for (const row of rows) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const outcome = await attempt({ account: row.account, webUserId: row.web_user_id })
|
||||||
|
if (outcome !== 'pending') log.info('a deleted account’s queued unlink finished', { account: row.account, outcome })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('retrying queued unlinks failed (will try on the next reconnect)', { error: err.message })
|
||||||
|
} finally {
|
||||||
|
inFlight = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return inFlight
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A live `mob.login`: when this account is queued and the shard no longer
|
||||||
|
* reports the deleted user's id on it, the row is done. Never on a backfill —
|
||||||
|
* a replayed login from before the account was linked carries no `webId` and
|
||||||
|
* would clear a row whose tag is still there.
|
||||||
|
*/
|
||||||
|
async function seenLogin({ account, webId }) {
|
||||||
|
if (!account) return
|
||||||
|
const id = webId === undefined || webId === null || webId === '' ? null : Number(webId)
|
||||||
|
const dropped = await pendingDb.clearIfNotAttributed(account, Number.isFinite(id) ? id : null)
|
||||||
|
if (dropped) log.info('a queued unlink cleared itself: the shard no longer attributes the account', { account })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin → Shard's list: what a deletion left linked on the shard. */
|
||||||
|
async function listForAdmin() {
|
||||||
|
const rows = await pendingDb.listAll()
|
||||||
|
return rows.map((r) => ({
|
||||||
|
account: r.account,
|
||||||
|
webUserId: r.web_user_id,
|
||||||
|
state: r.state,
|
||||||
|
queuedAt: r.queued_at,
|
||||||
|
attempts: Number(r.attempts) || 0,
|
||||||
|
lastAttemptAt: r.last_attempt_at || null,
|
||||||
|
lastError: r.last_error || null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function _reset() {
|
||||||
|
inFlight = null
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ACTOR, onUserDeleting, retryPending, seenLogin, listForAdmin, _reset }
|
||||||
@@ -21,6 +21,7 @@ const { settings: settingsModel, events: coreEvents } = require('../core')
|
|||||||
const broadcaster = require('./shardBroadcast')
|
const broadcaster = require('./shardBroadcast')
|
||||||
const shardPush = require('./shardPush')
|
const shardPush = require('./shardPush')
|
||||||
const shardEngagement = require('./shardEngagement')
|
const shardEngagement = require('./shardEngagement')
|
||||||
|
const accountDeletion = require('./accountDeletion')
|
||||||
const defaultLog = require('../core').logger('shard-ingest')
|
const defaultLog = require('../core').logger('shard-ingest')
|
||||||
|
|
||||||
// Notable kinds appended to the shard_events log. High-frequency/session kinds
|
// Notable kinds appended to the shard_events log. High-frequency/session kinds
|
||||||
@@ -103,7 +104,7 @@ async function resolveShardName(shard, deps) {
|
|||||||
|
|
||||||
// Apply the state-change side effect for a kind (if any). Returns a promise.
|
// Apply the state-change side effect for a kind (if any). Returns a promise.
|
||||||
async function applyStateChange(event, deps) {
|
async function applyStateChange(event, deps) {
|
||||||
const { shardState, uoLinkConfig, eventsReconcile, fromBackfill, log } = deps
|
const { shardState, uoLinkConfig, eventsReconcile, pendingUnlinks, fromBackfill, log } = deps
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case 'server.hello': {
|
case 'server.hello': {
|
||||||
const incoming = event.bootId || null
|
const incoming = event.bootId || null
|
||||||
@@ -142,6 +143,10 @@ async function applyStateChange(event, deps) {
|
|||||||
// missed by skipping these — core asks every module at its own boot.
|
// missed by skipping these — core asks every module at its own boot.
|
||||||
eventsReconcile()
|
eventsReconcile()
|
||||||
}
|
}
|
||||||
|
// The shard is (back) on the bridge, so an unlink an account deletion left
|
||||||
|
// queued can be asked again. Live only, like the reconcile above: a replay
|
||||||
|
// says nothing about the shard being up now. Not awaited; it never rejects.
|
||||||
|
if (!fromBackfill) pendingUnlinks.retryPending()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case 'server.shutdown':
|
case 'server.shutdown':
|
||||||
@@ -162,6 +167,16 @@ async function applyStateChange(event, deps) {
|
|||||||
y: event.y,
|
y: event.y,
|
||||||
z: event.z,
|
z: event.z,
|
||||||
})
|
})
|
||||||
|
// A queued unlink whose account now logs in without the deleted user's id
|
||||||
|
// is done. Live only: a replayed login from before the link carries no
|
||||||
|
// `webId` and would clear a row whose tag is still on the shard.
|
||||||
|
if (!fromBackfill && who.acct) {
|
||||||
|
try {
|
||||||
|
await pendingUnlinks.seenLogin({ account: who.acct, webId: event.webId })
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('could not check a login against the queued unlinks', { message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case 'mob.logout': {
|
case 'mob.logout': {
|
||||||
@@ -323,6 +338,10 @@ function resolveDeps(deps) {
|
|||||||
// reconcile must be able to see the call without a live event engine behind
|
// reconcile must be able to see the call without a live event engine behind
|
||||||
// it.
|
// it.
|
||||||
eventsReconcile: deps.eventsReconcile || (() => coreEvents.reconcile()),
|
eventsReconcile: deps.eventsReconcile || (() => coreEvents.reconcile()),
|
||||||
|
// MODULE_API 1.20.0 (ACCOUNT_DELETION.md §4.4): the unlinks an account
|
||||||
|
// deletion could not finish, retried when the shard says hello and cleared
|
||||||
|
// when a login shows the tag gone.
|
||||||
|
pendingUnlinks: deps.pendingUnlinks || accountDeletion,
|
||||||
// Not injectable — it is the caller's statement about this frame rather than
|
// Not injectable — it is the caller's statement about this frame rather than
|
||||||
// a dependency. It reaches `applyStateChange` because the reconcile below is
|
// a dependency. It reaches `applyStateChange` because the reconcile below is
|
||||||
// the one state change that must not act on a replay; see the note there.
|
// the one state change that must not act on a replay; see the note there.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const uoLinkClient = require('./uoLinkClient')
|
|||||||
const shardIngest = require('./shardIngest')
|
const shardIngest = require('./shardIngest')
|
||||||
const shardState = require('../model/shardState/shardState.model')
|
const shardState = require('../model/shardState/shardState.model')
|
||||||
const newsGump = require('./newsGump')
|
const newsGump = require('./newsGump')
|
||||||
|
const accountDeletion = require('./accountDeletion')
|
||||||
const log = require('../core').logger('uo-link-socket')
|
const log = require('../core').logger('uo-link-socket')
|
||||||
|
|
||||||
const BACKOFF_MIN_MS = 1000
|
const BACKOFF_MIN_MS = 1000
|
||||||
@@ -232,6 +233,10 @@ async function handleOpen() {
|
|||||||
backoff = BACKOFF_MIN_MS
|
backoff = BACKOFF_MIN_MS
|
||||||
await uoLinkConfig.recordStatus({ status: 'connected', statusDetail: null, pluginConnected: true }).catch(() => {})
|
await uoLinkConfig.recordStatus({ status: 'connected', statusDetail: null, pluginConnected: true }).catch(() => {})
|
||||||
await backfill()
|
await backfill()
|
||||||
|
// Unlinks an account deletion left queued while the bridge was away
|
||||||
|
// (ACCOUNT_DELETION.md §4.4). This is also the boot path: onBoot starts this
|
||||||
|
// socket, and boot itself never calls the shard. Not awaited; never rejects.
|
||||||
|
accountDeletion.retryPending()
|
||||||
}
|
}
|
||||||
|
|
||||||
// A ws.hello frame: mark it seen and, on a protocol mismatch, record the error
|
// A ws.hello frame: mark it seen and, on a protocol mismatch, record the error
|
||||||
|
|||||||
@@ -1452,10 +1452,10 @@
|
|||||||
"Admin · Shard"
|
"Admin · Shard"
|
||||||
],
|
],
|
||||||
"summary": "Get uo-link config + live status + ingestion stats (admin only)",
|
"summary": "Get uo-link config + live status + ingestion stats (admin only)",
|
||||||
"description": "",
|
"description": "pendingUnlinks lists the game accounts a website account deletion left linked on the shard: state pending (the shard was away; retried when the bridge reconnects) or refused (a protected staff account; shard staff clear it by hand). A row goes once the shard reports a login on the account without the deleted user id.",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Masked config, health and ingestion stats",
|
"description": "Masked config, health, ingestion stats and pendingUnlinks",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|||||||
Reference in New Issue
Block a user