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:
@@ -512,6 +512,30 @@ CREATE TABLE IF NOT EXISTS shard_presence (
|
||||
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Admin email invites (Protocol 2.0 provisioning). A staff member invites someone
|
||||
-- by email at a pre-chosen access level; the invitee accepts via a tokened link,
|
||||
-- which creates their website user at that role (and optionally a linked game
|
||||
-- account). Only the sha256 hash of the opaque token is stored — a DB read never
|
||||
-- yields a usable invite link, same as mobile_refresh_tokens. status tracks the
|
||||
-- lifecycle; accepted_user_id back-points at the created user. Single-use +
|
||||
-- expiring (enforced in the model on top of expires_at).
|
||||
CREATE TABLE IF NOT EXISTS user_invites (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque token
|
||||
email VARCHAR(255) NOT NULL,
|
||||
role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'player',
|
||||
status ENUM('pending','accepted','revoked') NOT NULL DEFAULT 'pending',
|
||||
invited_by INT NULL, -- staff user who sent it
|
||||
accepted_user_id INT NULL, -- the user created on accept
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
accepted_at DATETIME NULL,
|
||||
CONSTRAINT fk_user_invites_inviter FOREIGN KEY (invited_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_user_invites_user FOREIGN KEY (accepted_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_user_invites_email (email),
|
||||
INDEX idx_user_invites_status (status, expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Discord bot moderation core (Phase 2). These tables are owned by the bot
|
||||
-- process (its own DB pool, bot/src/db.js) — the main server never reads or
|
||||
-- writes them. They live in the same physical database as everything else
|
||||
@@ -836,6 +860,10 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45) NULL;
|
||||
-- Player self-registration mode: disabled | password | sso | both. Default off,
|
||||
-- so the system behaves exactly as today until an admin opts in.
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled');
|
||||
-- Game-account signup (Protocol 2.0 hybrid mode): whether a signed-in website user
|
||||
-- may provision a linked game account from the site. Default off; the shard's own
|
||||
-- signup mode still has the final say (a 'game'-mode shard refuses regardless).
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('game_account_signup', 'disabled');
|
||||
|
||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
|
||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
|
||||
|
||||
47
server/src/model/invites/invites.db.js
Normal file
47
server/src/model/invites/invites.db.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLS =
|
||||
'id, token_hash, email, role, status, invited_by, accepted_user_id, expires_at, created_at, accepted_at'
|
||||
|
||||
async function insert({ tokenHash, email, role, invitedBy, expiresAt }) {
|
||||
const res = await query(
|
||||
`INSERT INTO user_invites (token_hash, email, role, invited_by, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[tokenHash, email, role, invitedBy ?? null, expiresAt],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
async function getById(id) {
|
||||
const rows = await query(`SELECT ${COLS} FROM user_invites WHERE id = ? LIMIT 1`, [id])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function findByTokenHash(tokenHash) {
|
||||
const rows = await query(`SELECT ${COLS} FROM user_invites WHERE token_hash = ? LIMIT 1`, [tokenHash])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
const listRecent = (limit) =>
|
||||
query(`SELECT ${COLS} FROM user_invites ORDER BY created_at DESC LIMIT ?`, [limit])
|
||||
|
||||
// Mark accepted only if still pending (atomic guard against a double-accept race).
|
||||
// Returns rows changed (1 = we won, 0 = already used/revoked).
|
||||
async function markAccepted(id, userId) {
|
||||
const res = await query(
|
||||
`UPDATE user_invites SET status = 'accepted', accepted_user_id = ?, accepted_at = NOW()
|
||||
WHERE id = ? AND status = 'pending'`,
|
||||
[userId, id],
|
||||
)
|
||||
return res.affectedRows || 0
|
||||
}
|
||||
|
||||
async function revoke(id) {
|
||||
const res = await query(
|
||||
`UPDATE user_invites SET status = 'revoked' WHERE id = ? AND status = 'pending'`,
|
||||
[id],
|
||||
)
|
||||
return res.affectedRows || 0
|
||||
}
|
||||
|
||||
module.exports = { insert, getById, findByTokenHash, listRecent, markAccepted, revoke }
|
||||
73
server/src/model/invites/invites.model.js
Normal file
73
server/src/model/invites/invites.model.js
Normal file
@@ -0,0 +1,73 @@
|
||||
// Admin email invites. A staff member invites someone by email at a pre-chosen
|
||||
// access level; the invitee accepts via a tokened link that creates their website
|
||||
// user at that role. The opaque token lives only in the emailed link — the DB
|
||||
// stores just its sha256 hash (like mobile refresh tokens), so a DB read never
|
||||
// yields a usable invite. Invites are single-use and expiring.
|
||||
|
||||
const crypto = require('crypto')
|
||||
const db = require('./invites.db')
|
||||
|
||||
const DEFAULT_TTL_DAYS = 7
|
||||
|
||||
function hashToken(raw) {
|
||||
return crypto.createHash('sha256').update(String(raw)).digest('hex')
|
||||
}
|
||||
|
||||
// Public-safe shape (never exposes the token hash).
|
||||
function toSafe(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
status: row.status,
|
||||
invitedBy: row.invited_by,
|
||||
acceptedUserId: row.accepted_user_id,
|
||||
expiresAt: row.expires_at,
|
||||
createdAt: row.created_at,
|
||||
acceptedAt: row.accepted_at,
|
||||
expired: new Date(row.expires_at).getTime() < Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Create an invite. Returns { invite, token } — the plaintext token is returned
|
||||
// ONCE (for the email link) and never stored or recoverable afterwards.
|
||||
async function create({ email, role, invitedBy, ttlDays = DEFAULT_TTL_DAYS }) {
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000)
|
||||
const id = await db.insert({ tokenHash: hashToken(token), email, role, invitedBy, expiresAt })
|
||||
return { invite: toSafe(await db.getById(id)), token }
|
||||
}
|
||||
|
||||
// Resolve a pending, unexpired invite from its plaintext token, else null. Returns
|
||||
// the RAW row (incl. id) for the accept flow; callers sanitize with publicView.
|
||||
async function findValidByToken(token) {
|
||||
if (!token) return null
|
||||
const row = await db.findByTokenHash(hashToken(token))
|
||||
if (!row || row.status !== 'pending') return null
|
||||
if (new Date(row.expires_at).getTime() < Date.now()) return null
|
||||
return row
|
||||
}
|
||||
|
||||
// Atomically consume a pending invite (double-accept-safe). Returns true if this
|
||||
// call won the race and bound the invite to userId.
|
||||
async function accept(id, userId) {
|
||||
return (await db.markAccepted(id, userId)) === 1
|
||||
}
|
||||
|
||||
const revoke = (id) => db.revoke(id)
|
||||
|
||||
async function list(limit = 100) {
|
||||
const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
|
||||
const rows = await db.listRecent(n)
|
||||
return rows.map(toSafe)
|
||||
}
|
||||
|
||||
// A minimal, safe view of an invite for the (unauthenticated) accept page —
|
||||
// only what the form needs, never the token or internal ids.
|
||||
function publicView(row) {
|
||||
if (!row) return null
|
||||
return { email: row.email, role: row.role }
|
||||
}
|
||||
|
||||
module.exports = { create, findValidByToken, accept, revoke, list, publicView, toSafe, hashToken }
|
||||
@@ -32,6 +32,13 @@ function registrationFlags(mode) {
|
||||
}
|
||||
}
|
||||
|
||||
// Game-account signup (Protocol 2.0 hybrid mode). Off unless an admin opts in;
|
||||
// the shard's own signup mode still has the final say when we call the sidecar.
|
||||
const GAME_SIGNUP_KEY = 'game_account_signup'
|
||||
async function isGameAccountSignupEnabled() {
|
||||
return (await settingsDb.get(GAME_SIGNUP_KEY)) === 'enabled'
|
||||
}
|
||||
|
||||
async function get(key) {
|
||||
return settingsDb.get(key)
|
||||
}
|
||||
@@ -78,4 +85,6 @@ module.exports = {
|
||||
REGISTRATION_MODES,
|
||||
getRegistrationMode,
|
||||
registrationFlags,
|
||||
GAME_SIGNUP_KEY,
|
||||
isGameAccountSignupEnabled,
|
||||
}
|
||||
|
||||
@@ -33,4 +33,10 @@ async function isOwnedBy(account, userId) {
|
||||
const remove = (account, userId) =>
|
||||
query('DELETE FROM shard_account_links WHERE account = ? AND user_id = ?', [account, userId])
|
||||
|
||||
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove }
|
||||
// Drop the mirror for an account regardless of which user held it — used to
|
||||
// reconcile when the tie is severed at the source (an in-game [unlink →
|
||||
// account.unlinked event, or a site-side DELETE /link/{account}).
|
||||
const removeByAccount = (account) =>
|
||||
query('DELETE FROM shard_account_links WHERE account = ?', [account])
|
||||
|
||||
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount }
|
||||
|
||||
@@ -31,4 +31,7 @@ async function getByAccount(account) {
|
||||
|
||||
const unlink = (account, userId) => db.remove(account, userId)
|
||||
|
||||
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink }
|
||||
// Drop the local mirror for an account (source-of-truth severed elsewhere).
|
||||
const removeByAccount = (account) => db.removeByAccount(account)
|
||||
|
||||
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink, removeByAccount }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -326,6 +326,126 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/invite/{token}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"summary": "Look up an email invite by token",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Invite details",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"404": {
|
||||
"description": "Invalid or expired invite",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/invite/{token}/accept": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"summary": "Accept an email invite (creates the account at the invited role)",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Account created and session issued",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LoginResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Invalid or expired invite",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Username taken or invite already used",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"requestBody": {}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/logout": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -7148,6 +7268,239 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users/{id}/shard/link/{account}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Admin · Users"
|
||||
],
|
||||
"summary": "Unlink a game account from this user (admin only)",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "User id."
|
||||
},
|
||||
{
|
||||
"name": "account",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Game account to unlink."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Unlinked",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"account": {
|
||||
"type": "string"
|
||||
},
|
||||
"unlinked": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"403": {
|
||||
"description": "Protected staff account (refused by shard)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not linked",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
},
|
||||
"502": {
|
||||
"description": "Bad Gateway"
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/invites": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin · Invites"
|
||||
],
|
||||
"summary": "Create and email an account invite at a chosen access level",
|
||||
"description": "",
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Invite created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {}
|
||||
},
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Invites"
|
||||
],
|
||||
"summary": "List recent invites (no tokens)",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Invites, newest first",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/invites/{id}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Admin · Invites"
|
||||
],
|
||||
"summary": "Revoke a pending invite",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "Invite id."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Revoked",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"404": {
|
||||
"description": "No pending invite to revoke",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/uo-link/config": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -7977,6 +8330,100 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/player/shard/account": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Player · Shard"
|
||||
],
|
||||
"summary": "Create a game account (hybrid signup) and link it to the caller",
|
||||
"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.",
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Account created and linked",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"account": {
|
||||
"type": "string"
|
||||
},
|
||||
"linked": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Validation error or rejected name/password",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Game-account signup unavailable (site or shard)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Account name already taken",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Per-IP account cap reached",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
},
|
||||
"503": {
|
||||
"description": "Shard unavailable — retry",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {}
|
||||
}
|
||||
},
|
||||
"/api/v1/player/shard/accounts": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
78
server/test/invites.test.js
Normal file
78
server/test/invites.test.js
Normal file
@@ -0,0 +1,78 @@
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// Exercise invite create/lookup/single-use accept against an in-memory fake by
|
||||
// monkeypatching the shared db module the model require()s. No DB.
|
||||
const db = require('../src/model/invites/invites.db')
|
||||
const invites = require('../src/model/invites/invites.model')
|
||||
|
||||
let rows
|
||||
let nextId
|
||||
const saved = {}
|
||||
|
||||
beforeEach(() => {
|
||||
rows = []
|
||||
nextId = 1
|
||||
for (const k of ['insert', 'getById', 'findByTokenHash', 'markAccepted', 'revoke']) saved[k] = db[k]
|
||||
db.insert = async ({ tokenHash, email, role, invitedBy, expiresAt }) => {
|
||||
const id = nextId++
|
||||
rows.push({ id, token_hash: tokenHash, email, role, status: 'pending', invited_by: invitedBy ?? null, accepted_user_id: null, expires_at: expiresAt, created_at: new Date(), accepted_at: null })
|
||||
return id
|
||||
}
|
||||
db.getById = async (id) => rows.find((r) => r.id === id) || null
|
||||
db.findByTokenHash = async (h) => rows.find((r) => r.token_hash === h) || null
|
||||
db.markAccepted = async (id, userId) => {
|
||||
const row = rows.find((r) => r.id === id && r.status === 'pending')
|
||||
if (!row) return 0
|
||||
row.status = 'accepted'
|
||||
row.accepted_user_id = userId
|
||||
return 1
|
||||
}
|
||||
db.revoke = async (id) => {
|
||||
const row = rows.find((r) => r.id === id && r.status === 'pending')
|
||||
if (!row) return 0
|
||||
row.status = 'revoked'
|
||||
return 1
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of Object.keys(saved)) db[k] = saved[k]
|
||||
})
|
||||
|
||||
test('create stores only the token hash, never the plaintext token', async () => {
|
||||
const { invite, token } = await invites.create({ email: 'a@b.com', role: 'player', invitedBy: 1 })
|
||||
assert.ok(token && token.length >= 20)
|
||||
assert.equal(rows[0].token_hash, invites.hashToken(token))
|
||||
assert.notEqual(rows[0].token_hash, token) // hash, not the raw token
|
||||
assert.equal(invite.email, 'a@b.com')
|
||||
assert.equal(invite.role, 'player')
|
||||
assert.equal(invite.status, 'pending')
|
||||
})
|
||||
|
||||
test('findValidByToken resolves a pending token and rejects a wrong/used one', async () => {
|
||||
const { token } = await invites.create({ email: 'a@b.com', role: 'moderator', invitedBy: 1 })
|
||||
assert.ok(await invites.findValidByToken(token))
|
||||
assert.equal(await invites.findValidByToken('not-a-real-token'), null)
|
||||
})
|
||||
|
||||
test('accept is single-use — the second accept loses the race', async () => {
|
||||
const { token } = await invites.create({ email: 'a@b.com', role: 'player', invitedBy: 1 })
|
||||
const row = await invites.findValidByToken(token)
|
||||
assert.equal(await invites.accept(row.id, 55), true)
|
||||
assert.equal(await invites.accept(row.id, 66), false) // already consumed
|
||||
assert.equal(await invites.findValidByToken(token), null) // no longer pending
|
||||
})
|
||||
|
||||
test('an expired invite is not valid (exercises the expiry branch, not a bad token)', async () => {
|
||||
const { token } = await invites.create({ email: 'a@b.com', role: 'player', invitedBy: 1, ttlDays: -1 })
|
||||
// The token itself is correct and the row is pending — only expires_at rejects it.
|
||||
assert.ok(rows[0] && rows[0].status === 'pending')
|
||||
assert.equal(await invites.findValidByToken(token), null)
|
||||
})
|
||||
|
||||
test('revoke makes a pending invite unusable', async () => {
|
||||
const { invite, token } = await invites.create({ email: 'a@b.com', role: 'player', invitedBy: 1 })
|
||||
assert.equal(await invites.revoke(invite.id), 1)
|
||||
assert.equal(await invites.findValidByToken(token), null)
|
||||
})
|
||||
@@ -12,6 +12,7 @@ function makeDeps() {
|
||||
governorUpsert: [],
|
||||
presenceSet: [],
|
||||
houseRegistry: [], houseRemove: [],
|
||||
linkRemove: [],
|
||||
appended: [], broadcast: [],
|
||||
}
|
||||
const noop = async () => {}
|
||||
@@ -29,6 +30,7 @@ function makeDeps() {
|
||||
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
|
||||
addEconomySample: noop,
|
||||
},
|
||||
shardLinks: { removeByAccount: async (account) => { calls.linkRemove.push(account) } },
|
||||
uoLinkConfig: { recordStatus: noop },
|
||||
broadcast: (ev) => { calls.broadcast.push(ev) },
|
||||
log: { warn() {}, info() {}, error() {} },
|
||||
@@ -91,3 +93,27 @@ test('region.enter is broadcast-only — not logged, no state side effect', asyn
|
||||
assert.equal(deps.calls.appended.length, 0)
|
||||
assert.equal(deps.calls.broadcast.length, 1) // still surfaced live
|
||||
})
|
||||
|
||||
test('account.unlinked reconciles the local link mirror and is logged', async () => {
|
||||
const deps = makeDeps()
|
||||
const r = await shardIngest.ingest(
|
||||
{ kind: 'account.unlinked', origin: 'in-game', account: 'bob', websiteUserId: '9931', t: 9 }, deps)
|
||||
assert.deepEqual(deps.calls.linkRemove, ['bob']) // mirror dropped
|
||||
assert.equal(r.logged, true) // provisioning audit trail
|
||||
assert.equal(deps.calls.appended[0].kind, 'account.unlinked')
|
||||
})
|
||||
|
||||
test('account.audit is logged (provisioning history) but has no state side effect', async () => {
|
||||
const deps = makeDeps()
|
||||
const r = await shardIngest.ingest(
|
||||
{ kind: 'account.audit', origin: 'web', action: 'create', actor: 'web:jane', target: 'bob', t: 10 }, deps)
|
||||
assert.equal(r.logged, true)
|
||||
assert.equal(deps.calls.linkRemove.length, 0)
|
||||
assert.equal(deps.calls.appended[0].kind, 'account.audit')
|
||||
})
|
||||
|
||||
test('account.audit / account.unlinked are NOT on the public SSE allowlist', () => {
|
||||
const broadcast = require('../src/utils/shardBroadcast')
|
||||
assert.equal(broadcast.PUBLIC_KINDS.has('account.audit'), false)
|
||||
assert.equal(broadcast.PUBLIC_KINDS.has('account.unlinked'), false)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user