feat(provisioning): game-account signup, admin email invites, unlink (2.0)

Phase 5: the account-provisioning backend — link-only stays, plus hybrid
self-signup, an admin email-invite tool, and site-side unlink.

- uoLinkClient.createAccount / unlinkAccount (v2). Password is forwarded to the
  shard (hashed there) and never stored/logged; the end-user browser IP is passed
  for the shard's per-IP cap; actor is stamped server-side.
- Hybrid signup: POST /player/shard/account provisions a game account (its own
  username + password) for the signed-in user and mirrors the link locally. Gated
  by the new game_account_signup setting AND the shard's own mode (mapped 403/409/
  429/400/503). Serves both self-serve signup and the invite-accept game step.
- Email invites: user_invites table (sha256 token hash, single-use, expiring);
  invites model + admin CRUD (POST/GET/DELETE /admin/invites, admin-only) +
  mailer.sendInvite (falls back to returning the accept link if email is off);
  public token-gated accept (GET /auth/invite/:token, POST .../accept) creates the
  user at the invite's preset role and logs them in, bypassing the registration
  gate. Accept is race-safe (atomic single-use; rolls back the user if it loses).
- Admin unlink: DELETE /admin/users/:id/shard/link/:account (admin-only) + local
  mirror drop; account.unlinked ingest reconciles the mirror when a player runs
  [unlink in game. account.audit / account.unlinked are logged (admin channel
  only — never on the public SSE allowlist).

Tests: invites model (hashing, single-use, expiry, revoke) + account.* ingest
reconcile/visibility. Full suite 193/193; swagger regenerated.

Refs .plans/protocol2-integration.md (Phase 5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 15:50:49 -05:00
parent 55a3adea99
commit 91c206bf76
20 changed files with 1150 additions and 8 deletions

View File

@@ -122,4 +122,36 @@ async function sendTest(to) {
}
}
module.exports = { isConfigured, sendContactMessage, sendTest }
/**
* Send an account invite. `to` is the invitee's email, `acceptUrl` the tokened
* accept link, `role` their assigned access level, `invitedByName` optional. If
* email is not configured, returns { sent: false, reason: 'NOT_CONFIGURED' } so
* the caller can surface the accept link for the admin to share manually rather
* than throwing. Throws only on an actual send failure.
*/
async function sendInvite({ to, acceptUrl, role, invitedByName }) {
const built = await buildTransport()
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
const { transport, config } = built
const roleLabel = role && role !== 'player' ? ` as ${role}` : ''
const by = invitedByName ? ` by ${invitedByName}` : ''
try {
await transport.sendMail({
from: fromHeader(config),
to,
subject: 'Your UOMysticmoon invitation',
text:
`You have been invited${by} to join UOMysticmoon${roleLabel}.\n\n` +
`Accept your invitation and set up your account here:\n${acceptUrl}\n\n` +
`This link is single-use and will expire. If you weren't expecting this, you can ignore it.`,
})
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Invite send OK', lastVerifiedAt: new Date() })
return { sent: true }
} catch (err) {
log.error('invite send failed', err)
await emailConfig.recordStatus({ status: 'error', statusDetail: err.message })
throw err
}
}
module.exports = { isConfigured, sendContactMessage, sendTest, sendInvite }

View File

@@ -14,6 +14,7 @@
const shardEventsModel = require('../model/shardEvents/shardEvents.model')
const shardStateModel = require('../model/shardState/shardState.model')
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
const broadcaster = require('./shardBroadcast')
const defaultLog = require('./logger')('shard-ingest')
@@ -41,6 +42,9 @@ const LOGGED_KINDS = new Set([
'server.crashed',
// Protocol 2.0: a real-time guild join (the board itself is state, not logged).
'guild.join',
// Protocol 2.0 provisioning audit (admin channel only — not in PUBLIC_KINDS).
'account.audit',
'account.unlinked',
])
// Tracks the current shard boot id so a restart (changed bootId on server.hello)
@@ -168,7 +172,12 @@ async function applyStateChange(event, deps) {
case 'house.remove':
await shardState.removeHouse(event.serial)
return
// guild.join → logged (real-time feed); region.enter → broadcast-only.
case 'account.unlinked':
// A player ran [unlink in game (or a site-side unlink echoed back) — drop
// our local link mirror so attribution stops immediately.
if (event.account) await deps.shardLinks.removeByAccount(event.account)
return
// guild.join / account.audit → logged; region.enter → broadcast-only.
default:
// No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and
// broadcasting still happen in ingest().
@@ -182,6 +191,7 @@ async function ingest(event, deps = {}) {
const d = {
shardEvents: deps.shardEvents || shardEventsModel,
shardState: deps.shardState || shardStateModel,
shardLinks: deps.shardLinks || shardLinksModel,
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
broadcast: deps.broadcast || broadcaster.broadcast,
log: deps.log || defaultLog,

View File

@@ -114,6 +114,20 @@ const getPresence = () => call('/online') // aggregate population (count + byFac
const confirmLink = (code, websiteUserId) =>
call('/link/confirm', { method: 'POST', body: { code, websiteUserId: String(websiteUserId) } })
const linkLookup = (account) => call(`/link/${encodeURIComponent(account)}`)
// Account provisioning (Protocol 2.0). createAccount provisions a game account and
// auto-links it to the website user in one step; `ip` is the END USER's browser IP
// (read from the request), which the shard needs for its per-IP account cap — the
// sidecar only sees our server. The password is hashed on the shard and never
// appears in any reply/event/log. unlinkAccount severs a game account's tie from
// the site side. `actor` is the staff/website id, recorded in the shard audit.
const createAccount = ({ actor, account, password, websiteUserId, ip }) =>
call('/accounts/create', {
method: 'POST',
body: { actor, account, password, websiteUserId: websiteUserId == null ? undefined : String(websiteUserId), ip },
})
const unlinkAccount = ({ actor, account }) =>
call(`/link/${encodeURIComponent(account)}`, { method: 'DELETE', body: { actor } })
const postTownCrier = ({ id, lines, durationSec }) =>
call('/towncrier', { method: 'POST', body: { id, lines, durationSec } })
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' })
@@ -160,6 +174,8 @@ module.exports = {
getPresence,
confirmLink,
linkLookup,
createAccount,
unlinkAccount,
postTownCrier,
deleteTownCrier,
postNews,