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:
@@ -14,6 +14,7 @@ const emailConfig = require('./emailConfig.controller')
|
||||
const uoLink = require('./uoLink.controller')
|
||||
const shardOps = require('./shardOps.controller')
|
||||
const usersShard = require('./usersShard.controller')
|
||||
const invites = require('./invites.controller')
|
||||
const selfShard = require('../player/shard.controller')
|
||||
const moderation = require('./moderation.controller')
|
||||
const pagesCtrl = require('./pages.controller')
|
||||
@@ -1267,6 +1268,61 @@ adminRouter.get(
|
||||
validate,
|
||||
usersShard.getStanding,
|
||||
)
|
||||
adminRouter.delete(
|
||||
'/users/:id/shard/link/:account',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Unlink a game account from this user (admin only)'
|
||||
// #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' }
|
||||
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('id').isInt(),
|
||||
param('account').matches(SHARD_ACCOUNT_RE),
|
||||
validate,
|
||||
usersShard.unlinkAccount,
|
||||
)
|
||||
|
||||
// ── Email invites (admin only) ─────────────────────────────────────────────
|
||||
adminRouter.post(
|
||||
'/invites',
|
||||
// #swagger.tags = ['Admin · Invites']
|
||||
// #swagger.summary = 'Create and email an account invite at a chosen access level'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email","role"], properties: { email: { type: "string" }, role: { type: "string" } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Invite created', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
adminOnly,
|
||||
body('email').isEmail().isLength({ max: 255 }),
|
||||
body('role').isIn(['admin', 'editor', 'moderator', 'player']),
|
||||
validate,
|
||||
invites.create,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/invites',
|
||||
// #swagger.tags = ['Admin · Invites']
|
||||
// #swagger.summary = 'List recent invites (no tokens)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Invites, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
adminOnly,
|
||||
invites.list,
|
||||
)
|
||||
adminRouter.delete(
|
||||
'/invites/:id',
|
||||
// #swagger.tags = ['Admin · Invites']
|
||||
// #swagger.summary = 'Revoke a pending invite'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Invite id.' }
|
||||
/* #swagger.responses[200] = { description: 'Revoked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No pending invite to revoke', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
invites.revoke,
|
||||
)
|
||||
|
||||
// ── uo-link sidecar control (admin only) ──────────────────────────────────
|
||||
// Connection config (base/ws URL + token + protocol + enabled) and the town
|
||||
|
||||
84
server/src/router/v1/admin/invites.controller.js
Normal file
84
server/src/router/v1/admin/invites.controller.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// ── Admin: email invites ───────────────────────────────────────────────────
|
||||
//
|
||||
// Admin-only. A staff member invites someone by email at a pre-chosen access
|
||||
// level; the invitee accepts via a tokened link (auth/invite.controller) which
|
||||
// creates their website user at that role. The plaintext token exists only in the
|
||||
// emailed link and in the create response (so the admin can copy the link if email
|
||||
// isn't configured); the DB stores only its hash.
|
||||
|
||||
const invites = require('../../../model/invites/invites.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const mailer = require('../../../utils/mailer')
|
||||
|
||||
const log = require('../../../utils/logger')('admin-invites')
|
||||
|
||||
const ROLES = ['admin', 'editor', 'moderator', 'player']
|
||||
|
||||
function baseUrl() {
|
||||
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function acceptUrl(token) {
|
||||
return `${baseUrl()}/invite/${token}`
|
||||
}
|
||||
|
||||
// POST /admin/invites — create an invite and email it.
|
||||
async function create(req, res) {
|
||||
const email = String(req.body.email || '').trim()
|
||||
const role = req.body.role
|
||||
if (!email || !ROLES.includes(role)) {
|
||||
return res.status(400).json({ message: 'A valid email and role are required.' })
|
||||
}
|
||||
try {
|
||||
const { invite, token } = await invites.create({ email, role, invitedBy: req.user.id })
|
||||
const url = acceptUrl(token)
|
||||
|
||||
// Send the email; if mail isn't configured, hand the link back so the admin
|
||||
// can share it manually. A send failure doesn't delete the invite — surface it.
|
||||
let emailed = false
|
||||
let emailError = null
|
||||
try {
|
||||
const result = await mailer.sendInvite({ to: email, acceptUrl: url, role, invitedByName: req.user.username })
|
||||
emailed = Boolean(result.sent)
|
||||
} catch (err) {
|
||||
emailError = err.message
|
||||
log.warn('invite email failed (invite still created)', { id: invite.id, message: err.message })
|
||||
}
|
||||
|
||||
await activity.log({ req, userId: req.user.id, action: 'invite.create', detail: { email, role, emailed } })
|
||||
log.info('invite created', { id: invite.id, email, role, emailed, by: req.user.username })
|
||||
|
||||
// The accept link is returned only when email did not deliver, so the admin
|
||||
// can copy it. When emailed, we don't echo the token.
|
||||
return res.status(201).json({ invite, emailed, acceptUrl: emailed ? undefined : url, emailError })
|
||||
} catch (err) {
|
||||
log.error('create invite', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/invites — recent invites (no tokens).
|
||||
async function list(req, res) {
|
||||
try {
|
||||
return res.json(await invites.list(req.query.limit))
|
||||
} catch (err) {
|
||||
log.error('list invites', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /admin/invites/:id — revoke a pending invite.
|
||||
async function revoke(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const changed = await invites.revoke(id)
|
||||
if (!changed) return res.status(404).json({ message: 'No pending invite to revoke.' })
|
||||
await activity.log({ req, userId: req.user.id, action: 'invite.revoke', detail: { id } })
|
||||
return res.json({ id, revoked: true })
|
||||
} catch (err) {
|
||||
log.error('revoke invite', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { create, list, revoke }
|
||||
@@ -10,6 +10,8 @@
|
||||
const users = require('../../../model/users/users.model')
|
||||
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
||||
const shardState = require('../../../model/shardState/shardState.model')
|
||||
const uoLinkClient = require('../../../utils/uoLinkClient')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const { salesForAccounts } = require('../../../utils/shardSales')
|
||||
|
||||
const log = require('../../../utils/logger')('admin-user-shard')
|
||||
@@ -101,4 +103,40 @@ async function getStanding(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getUser, listAccounts, getSales, getHouses, getOnline, getStanding }
|
||||
// DELETE /admin/users/:id/shard/link/:account — unlink a game account from this
|
||||
// user, site-side. `actor` is stamped from the session (never the browser). On
|
||||
// success the sidecar clears the WebsiteUserId tag on the shard and we drop the
|
||||
// local mirror so attribution stops immediately.
|
||||
async function unlinkAccount(req, res) {
|
||||
const { account } = req.params
|
||||
try {
|
||||
const ctx = await accountsForUser(Number(req.params.id))
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
// Only unlink an account actually linked to THIS user (avoid cross-user unlink).
|
||||
if (!ctx.accounts.includes(account)) {
|
||||
return res.status(404).json({ message: 'That account is not linked to this user.' })
|
||||
}
|
||||
const result = await uoLinkClient.unlinkAccount({ actor: req.user.username, account })
|
||||
if (result.ok) {
|
||||
await shardLinks.removeByAccount(account)
|
||||
await activity.log({ req, userId: ctx.user.id, action: 'shard.account.unlink', detail: { account } })
|
||||
log.info('game account unlinked', { account, userId: ctx.user.id, actor: req.user.username })
|
||||
return res.json({ account, unlinked: true })
|
||||
}
|
||||
if (result.status === 403) return res.status(403).json({ message: 'That account is protected and cannot be unlinked.' })
|
||||
if (result.status === 404) {
|
||||
// Not linked on the shard — reconcile our mirror anyway so the two agree.
|
||||
await shardLinks.removeByAccount(account)
|
||||
return res.status(404).json({ message: 'That account is not linked.' })
|
||||
}
|
||||
if (result.status === 503 || result.status === 0) {
|
||||
return res.status(503).json({ message: 'The game server is unavailable — try again shortly.' })
|
||||
}
|
||||
return res.status(502).json({ message: 'Could not reach the shard to unlink the account.' })
|
||||
} catch (err) {
|
||||
log.error('unlinkAccount', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getUser, listAccounts, getSales, getHouses, getOnline, getStanding, unlinkAccount }
|
||||
|
||||
@@ -188,4 +188,4 @@ async function me(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { login, register, loginTotp, logout, me, needsTotp, HONEYPOT_FIELD }
|
||||
module.exports = { login, register, loginTotp, logout, me, needsTotp, issueSession, HONEYPOT_FIELD }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const express = require('express')
|
||||
const { body } = require('express-validator')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const { login, register, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller')
|
||||
const { getInvite, acceptInvite } = require('./invite.controller')
|
||||
const { isLoggedIn } = require('../../../utils/auth')
|
||||
const { attachSession } = require('../../../auth/session.middleware')
|
||||
const { loginLimiter, registerLimiter } = require('../../../middleware/rateLimit')
|
||||
@@ -87,6 +88,38 @@ authRouter.post(
|
||||
loginTotp,
|
||||
)
|
||||
|
||||
// ── Email-invite acceptance (public, token-gated) ──────────────────────────
|
||||
authRouter.get(
|
||||
'/invite/:token',
|
||||
// #swagger.tags = ['Auth']
|
||||
// #swagger.summary = 'Look up an email invite by token'
|
||||
// #swagger.description = 'Returns the pre-assigned email + role for a valid, pending, unexpired invite so the accept form can render. 404 for anything not currently acceptable.'
|
||||
/* #swagger.responses[200] = { description: 'Invite details', content: { "application/json": { schema: { type: "object", properties: { email: { type: "string" }, role: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Invalid or expired invite', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('token').isString().isLength({ min: 8, max: 128 }),
|
||||
validate,
|
||||
getInvite,
|
||||
)
|
||||
authRouter.post(
|
||||
'/invite/:token/accept',
|
||||
// #swagger.tags = ['Auth']
|
||||
// #swagger.summary = 'Accept an email invite (creates the account at the invited role)'
|
||||
// #swagger.description = 'Creates the website user at the invite’s pre-assigned role and logs them in (sets the session cookie). Bypasses the player_registration gate — the invite is its own authority. Rate limited + honeypot-guarded like registration.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["username","password"], properties: { username: { type: "string" }, password: { type: "string" } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Account created and session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Invalid or expired invite', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Username taken or invite already used', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
...loginGuards,
|
||||
registerLimiter,
|
||||
param('token').isString().isLength({ min: 8, max: 128 }),
|
||||
body('username').isString().trim().isLength({ min: 3, max: 32 }),
|
||||
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||
body(HONEYPOT_FIELD).optional(),
|
||||
validate,
|
||||
acceptInvite,
|
||||
)
|
||||
|
||||
authRouter.post(
|
||||
'/logout',
|
||||
// #swagger.tags = ['Auth']
|
||||
|
||||
82
server/src/router/v1/auth/invite.controller.js
Normal file
82
server/src/router/v1/auth/invite.controller.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// ── Invite acceptance (public, token-gated) ────────────────────────────────
|
||||
//
|
||||
// The other end of the admin email-invite flow (admin/invites.controller). An
|
||||
// invitee opens the tokened link, sees their pre-assigned email + role, and sets
|
||||
// a username + password. Accepting creates their website user AT THE PRESET ROLE
|
||||
// (bypassing the player_registration gate — the invite is its own authority) and
|
||||
// logs them straight in. The optional "create game account" step afterwards reuses
|
||||
// POST /player/shard/account (players only), so it isn't handled here.
|
||||
|
||||
const invites = require('../../../model/invites/invites.model')
|
||||
const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const usernamePolicy = require('../../../auth/usernamePolicy')
|
||||
const { issueSession, HONEYPOT_FIELD } = require('./auth.controller')
|
||||
|
||||
const log = require('../../../utils/logger')('auth-invite')
|
||||
|
||||
// GET /auth/invite/:token — validate an invite and return what the accept form
|
||||
// needs (email + role). 404 for anything not currently acceptable so we never
|
||||
// distinguish "expired" from "revoked" from "never existed".
|
||||
async function getInvite(req, res) {
|
||||
try {
|
||||
const row = await invites.findValidByToken(req.params.token)
|
||||
if (!row) return res.status(404).json({ message: 'This invitation is invalid or has expired.' })
|
||||
return res.json(invites.publicView(row))
|
||||
} catch (err) {
|
||||
log.error('getInvite', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /auth/invite/:token/accept — create the user at the invite's role and log
|
||||
// them in. Honeypot + validation mirror register; the invite replaces the
|
||||
// registration-mode gate.
|
||||
async function acceptInvite(req, res) {
|
||||
// Honeypot: a filled hidden field means a bot.
|
||||
if (req.body[HONEYPOT_FIELD]) {
|
||||
log.warn('honeypot invite-accept hit', { ip: req.ip })
|
||||
return res.status(400).json({ message: 'Registration failed.' })
|
||||
}
|
||||
try {
|
||||
const row = await invites.findValidByToken(req.params.token)
|
||||
if (!row) return res.status(404).json({ message: 'This invitation is invalid or has expired.' })
|
||||
|
||||
const check = usernamePolicy.validateUsername(req.body.username)
|
||||
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||
|
||||
let user
|
||||
try {
|
||||
user = await users.createUser({
|
||||
username: check.name,
|
||||
password: req.body.password,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
emailVerified: true, // they proved control of the address by using the link
|
||||
})
|
||||
} catch (err) {
|
||||
if (users.isDuplicateUsername(err)) {
|
||||
return res.status(409).json({ message: 'That username is already taken.' })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
// Consume the invite atomically. If we lost a double-accept race, roll back the
|
||||
// user we just created so a spent invite never yields two accounts.
|
||||
const won = await invites.accept(row.id, user.id)
|
||||
if (!won) {
|
||||
await users.remove(user.id).catch(() => {})
|
||||
return res.status(409).json({ message: 'This invitation has already been used.' })
|
||||
}
|
||||
|
||||
await activity.log({ req, userId: user.id, action: 'invite.accept', detail: { inviteId: row.id, role: row.role } })
|
||||
log.info('invite accepted', { inviteId: row.id, userId: user.id, role: row.role, ip: req.ip })
|
||||
// New accounts never have TOTP yet — log straight in.
|
||||
return issueSession(req, res, user, 'local')
|
||||
} catch (err) {
|
||||
log.error('acceptInvite', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getInvite, acceptInvite }
|
||||
@@ -148,6 +148,25 @@ playerRouter.post(
|
||||
validate,
|
||||
shard.link,
|
||||
)
|
||||
playerRouter.post(
|
||||
'/shard/account',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
// #swagger.summary = 'Create a game account (hybrid signup) and link it to the caller'
|
||||
// #swagger.description = 'Provisions a new game account with its own username + password and auto-links it to the signed-in website user. Available only when game_account_signup is enabled and the shard accepts website signups. The password is hashed on the shard and never stored or logged by the site.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, linked: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error or rejected name/password', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Account name already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[429] = { description: 'Per-IP account cap reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
accountChangeLimiter,
|
||||
body('account').matches(/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/),
|
||||
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||
validate,
|
||||
shard.createGameAccount,
|
||||
)
|
||||
playerRouter.get(
|
||||
'/shard/accounts',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
const uoLinkClient = require('../../../utils/uoLinkClient')
|
||||
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
||||
const shardState = require('../../../model/shardState/shardState.model')
|
||||
const settings = require('../../../model/settings/settings.model')
|
||||
const { salesForAccounts } = require('../../../utils/shardSales')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
|
||||
@@ -147,4 +148,58 @@ async function getSales(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { link, listAccounts, roster, vendors, getChar, getSales }
|
||||
// Map a failed uoLinkClient.createAccount result to a user-facing HTTP response.
|
||||
// The password is never echoed anywhere; only the mapped reason is returned.
|
||||
function mapCreateAccountError(res, result) {
|
||||
const reason = (result.data && result.data.reason) || ''
|
||||
switch (result.status) {
|
||||
case 409:
|
||||
return res.status(409).json({ message: 'That account name is already taken.' })
|
||||
case 429:
|
||||
return res.status(429).json({ message: 'The account limit for your network has been reached.' })
|
||||
case 403:
|
||||
return res.status(403).json({ message: 'Game-account signups are not available on this shard right now.' })
|
||||
case 400:
|
||||
return res.status(400).json({ message: reason || 'The account name or password was not accepted.' })
|
||||
case 503:
|
||||
case 0:
|
||||
return res.status(503).json({ message: 'The game server is unavailable — try again shortly.' })
|
||||
default:
|
||||
return res.status(502).json({ message: 'Could not reach the shard to create the account.' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /player/shard/account — provision a GAME account for the signed-in website
|
||||
// user and auto-link it (Protocol 2.0 hybrid). Used by self-serve signup and the
|
||||
// invite-accept "create game account" step alike (both act as the signed-in user).
|
||||
// actor + websiteUserId are stamped from the session; the browser IP (req.ip,
|
||||
// trust-proxy configured) is forwarded for the shard's per-IP cap; the password is
|
||||
// never logged. Gated by the game_account_signup setting AND the shard's own mode.
|
||||
async function createGameAccount(req, res) {
|
||||
const { account, password } = req.body
|
||||
try {
|
||||
if (!(await settings.isGameAccountSignupEnabled())) {
|
||||
return res.status(403).json({ message: 'Game-account signup is not available right now.' })
|
||||
}
|
||||
const result = await uoLinkClient.createAccount({
|
||||
actor: req.user.username,
|
||||
account,
|
||||
password,
|
||||
websiteUserId: req.user.id,
|
||||
ip: req.ip,
|
||||
})
|
||||
if (result.ok) {
|
||||
// Mirror the link locally so the portal lists the account immediately.
|
||||
await shardLinks.link({ account, userId: req.user.id })
|
||||
await activity.log({ req, userId: req.user.id, action: 'shard.account.create', detail: { account } })
|
||||
log.info('game account created', { account, userId: req.user.id, ip: req.ip })
|
||||
return res.status(201).json({ account, linked: true })
|
||||
}
|
||||
return mapCreateAccountError(res, result)
|
||||
} catch (err) {
|
||||
log.error('player.shard.createGameAccount', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { link, listAccounts, roster, vendors, getChar, getSales, createGameAccount }
|
||||
|
||||
Reference in New Issue
Block a user