Protocol 2.0/2.1 uo-link integration — boards, cross-links, news gump, account provisioning #65
@@ -512,6 +512,30 @@ CREATE TABLE IF NOT EXISTS shard_presence (
|
|||||||
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
|
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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
|
-- 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
|
-- 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
|
-- 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,
|
-- Player self-registration mode: disabled | password | sso | both. Default off,
|
||||||
-- so the system behaves exactly as today until an admin opts in.
|
-- so the system behaves exactly as today until an admin opts in.
|
||||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled');
|
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 excerpt VARCHAR(400) NULL;
|
||||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT 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) {
|
async function get(key) {
|
||||||
return settingsDb.get(key)
|
return settingsDb.get(key)
|
||||||
}
|
}
|
||||||
@@ -78,4 +85,6 @@ module.exports = {
|
|||||||
REGISTRATION_MODES,
|
REGISTRATION_MODES,
|
||||||
getRegistrationMode,
|
getRegistrationMode,
|
||||||
registrationFlags,
|
registrationFlags,
|
||||||
|
GAME_SIGNUP_KEY,
|
||||||
|
isGameAccountSignupEnabled,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,4 +33,10 @@ async function isOwnedBy(account, userId) {
|
|||||||
const remove = (account, userId) =>
|
const remove = (account, userId) =>
|
||||||
query('DELETE FROM shard_account_links WHERE account = ? AND user_id = ?', [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)
|
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 uoLink = require('./uoLink.controller')
|
||||||
const shardOps = require('./shardOps.controller')
|
const shardOps = require('./shardOps.controller')
|
||||||
const usersShard = require('./usersShard.controller')
|
const usersShard = require('./usersShard.controller')
|
||||||
|
const invites = require('./invites.controller')
|
||||||
const selfShard = require('../player/shard.controller')
|
const selfShard = require('../player/shard.controller')
|
||||||
const moderation = require('./moderation.controller')
|
const moderation = require('./moderation.controller')
|
||||||
const pagesCtrl = require('./pages.controller')
|
const pagesCtrl = require('./pages.controller')
|
||||||
@@ -1267,6 +1268,61 @@ adminRouter.get(
|
|||||||
validate,
|
validate,
|
||||||
usersShard.getStanding,
|
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) ──────────────────────────────────
|
// ── uo-link sidecar control (admin only) ──────────────────────────────────
|
||||||
// Connection config (base/ws URL + token + protocol + enabled) and the town
|
// 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 users = require('../../../model/users/users.model')
|
||||||
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
||||||
const shardState = require('../../../model/shardState/shardState.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 { salesForAccounts } = require('../../../utils/shardSales')
|
||||||
|
|
||||||
const log = require('../../../utils/logger')('admin-user-shard')
|
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 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 { login, register, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller')
|
||||||
|
const { getInvite, acceptInvite } = require('./invite.controller')
|
||||||
const { isLoggedIn } = require('../../../utils/auth')
|
const { isLoggedIn } = require('../../../utils/auth')
|
||||||
const { attachSession } = require('../../../auth/session.middleware')
|
const { attachSession } = require('../../../auth/session.middleware')
|
||||||
const { loginLimiter, registerLimiter } = require('../../../middleware/rateLimit')
|
const { loginLimiter, registerLimiter } = require('../../../middleware/rateLimit')
|
||||||
@@ -87,6 +88,38 @@ authRouter.post(
|
|||||||
loginTotp,
|
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(
|
authRouter.post(
|
||||||
'/logout',
|
'/logout',
|
||||||
// #swagger.tags = ['Auth']
|
// #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,
|
validate,
|
||||||
shard.link,
|
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(
|
playerRouter.get(
|
||||||
'/shard/accounts',
|
'/shard/accounts',
|
||||||
// #swagger.tags = ['Player · Shard']
|
// #swagger.tags = ['Player · Shard']
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
const uoLinkClient = require('../../../utils/uoLinkClient')
|
const uoLinkClient = require('../../../utils/uoLinkClient')
|
||||||
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
||||||
const shardState = require('../../../model/shardState/shardState.model')
|
const shardState = require('../../../model/shardState/shardState.model')
|
||||||
|
const settings = require('../../../model/settings/settings.model')
|
||||||
const { salesForAccounts } = require('../../../utils/shardSales')
|
const { salesForAccounts } = require('../../../utils/shardSales')
|
||||||
const activity = require('../../../model/activity/activity.model')
|
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 shardEventsModel = require('../model/shardEvents/shardEvents.model')
|
||||||
const shardStateModel = require('../model/shardState/shardState.model')
|
const shardStateModel = require('../model/shardState/shardState.model')
|
||||||
|
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
|
||||||
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
|
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||||
const broadcaster = require('./shardBroadcast')
|
const broadcaster = require('./shardBroadcast')
|
||||||
const defaultLog = require('./logger')('shard-ingest')
|
const defaultLog = require('./logger')('shard-ingest')
|
||||||
@@ -41,6 +42,9 @@ const LOGGED_KINDS = new Set([
|
|||||||
'server.crashed',
|
'server.crashed',
|
||||||
// Protocol 2.0: a real-time guild join (the board itself is state, not logged).
|
// Protocol 2.0: a real-time guild join (the board itself is state, not logged).
|
||||||
'guild.join',
|
'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)
|
// 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':
|
case 'house.remove':
|
||||||
await shardState.removeHouse(event.serial)
|
await shardState.removeHouse(event.serial)
|
||||||
return
|
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:
|
default:
|
||||||
// No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and
|
// No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and
|
||||||
// broadcasting still happen in ingest().
|
// broadcasting still happen in ingest().
|
||||||
@@ -182,6 +191,7 @@ async function ingest(event, deps = {}) {
|
|||||||
const d = {
|
const d = {
|
||||||
shardEvents: deps.shardEvents || shardEventsModel,
|
shardEvents: deps.shardEvents || shardEventsModel,
|
||||||
shardState: deps.shardState || shardStateModel,
|
shardState: deps.shardState || shardStateModel,
|
||||||
|
shardLinks: deps.shardLinks || shardLinksModel,
|
||||||
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
|
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
|
||||||
broadcast: deps.broadcast || broadcaster.broadcast,
|
broadcast: deps.broadcast || broadcaster.broadcast,
|
||||||
log: deps.log || defaultLog,
|
log: deps.log || defaultLog,
|
||||||
|
|||||||
@@ -114,6 +114,20 @@ const getPresence = () => call('/online') // aggregate population (count + byFac
|
|||||||
const confirmLink = (code, websiteUserId) =>
|
const confirmLink = (code, websiteUserId) =>
|
||||||
call('/link/confirm', { method: 'POST', body: { code, websiteUserId: String(websiteUserId) } })
|
call('/link/confirm', { method: 'POST', body: { code, websiteUserId: String(websiteUserId) } })
|
||||||
const linkLookup = (account) => call(`/link/${encodeURIComponent(account)}`)
|
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 }) =>
|
const postTownCrier = ({ id, lines, durationSec }) =>
|
||||||
call('/towncrier', { method: 'POST', body: { id, lines, durationSec } })
|
call('/towncrier', { method: 'POST', body: { id, lines, durationSec } })
|
||||||
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||||
@@ -160,6 +174,8 @@ module.exports = {
|
|||||||
getPresence,
|
getPresence,
|
||||||
confirmLink,
|
confirmLink,
|
||||||
linkLookup,
|
linkLookup,
|
||||||
|
createAccount,
|
||||||
|
unlinkAccount,
|
||||||
postTownCrier,
|
postTownCrier,
|
||||||
deleteTownCrier,
|
deleteTownCrier,
|
||||||
postNews,
|
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": {
|
"/api/v1/auth/logout": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"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": {
|
"/api/v1/admin/uo-link/config": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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": {
|
"/api/v1/player/shard/accounts": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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: [],
|
governorUpsert: [],
|
||||||
presenceSet: [],
|
presenceSet: [],
|
||||||
houseRegistry: [], houseRemove: [],
|
houseRegistry: [], houseRemove: [],
|
||||||
|
linkRemove: [],
|
||||||
appended: [], broadcast: [],
|
appended: [], broadcast: [],
|
||||||
}
|
}
|
||||||
const noop = async () => {}
|
const noop = async () => {}
|
||||||
@@ -29,6 +30,7 @@ function makeDeps() {
|
|||||||
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
|
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
|
||||||
addEconomySample: noop,
|
addEconomySample: noop,
|
||||||
},
|
},
|
||||||
|
shardLinks: { removeByAccount: async (account) => { calls.linkRemove.push(account) } },
|
||||||
uoLinkConfig: { recordStatus: noop },
|
uoLinkConfig: { recordStatus: noop },
|
||||||
broadcast: (ev) => { calls.broadcast.push(ev) },
|
broadcast: (ev) => { calls.broadcast.push(ev) },
|
||||||
log: { warn() {}, info() {}, error() {} },
|
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.appended.length, 0)
|
||||||
assert.equal(deps.calls.broadcast.length, 1) // still surfaced live
|
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