diff --git a/server/db/schema.sql b/server/db/schema.sql index 920f741..a33942c 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -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; diff --git a/server/src/model/invites/invites.db.js b/server/src/model/invites/invites.db.js new file mode 100644 index 0000000..10a0313 --- /dev/null +++ b/server/src/model/invites/invites.db.js @@ -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 } diff --git a/server/src/model/invites/invites.model.js b/server/src/model/invites/invites.model.js new file mode 100644 index 0000000..c5652c9 --- /dev/null +++ b/server/src/model/invites/invites.model.js @@ -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 } diff --git a/server/src/model/settings/settings.model.js b/server/src/model/settings/settings.model.js index 3ab7b69..19e0d9f 100644 --- a/server/src/model/settings/settings.model.js +++ b/server/src/model/settings/settings.model.js @@ -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, } diff --git a/server/src/model/shardLinks/shardLinks.db.js b/server/src/model/shardLinks/shardLinks.db.js index 915b53d..76b0418 100644 --- a/server/src/model/shardLinks/shardLinks.db.js +++ b/server/src/model/shardLinks/shardLinks.db.js @@ -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 } diff --git a/server/src/model/shardLinks/shardLinks.model.js b/server/src/model/shardLinks/shardLinks.model.js index 9e1c7fd..3d0f907 100644 --- a/server/src/model/shardLinks/shardLinks.model.js +++ b/server/src/model/shardLinks/shardLinks.model.js @@ -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 } diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index ed7878f..c8a967d 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -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 diff --git a/server/src/router/v1/admin/invites.controller.js b/server/src/router/v1/admin/invites.controller.js new file mode 100644 index 0000000..e2f67e4 --- /dev/null +++ b/server/src/router/v1/admin/invites.controller.js @@ -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 } diff --git a/server/src/router/v1/admin/usersShard.controller.js b/server/src/router/v1/admin/usersShard.controller.js index fd3c5a1..c620b3e 100644 --- a/server/src/router/v1/admin/usersShard.controller.js +++ b/server/src/router/v1/admin/usersShard.controller.js @@ -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 } diff --git a/server/src/router/v1/auth/auth.controller.js b/server/src/router/v1/auth/auth.controller.js index a301874..c91d780 100644 --- a/server/src/router/v1/auth/auth.controller.js +++ b/server/src/router/v1/auth/auth.controller.js @@ -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 } diff --git a/server/src/router/v1/auth/auth.routes.js b/server/src/router/v1/auth/auth.routes.js index d30f004..031b9e0 100644 --- a/server/src/router/v1/auth/auth.routes.js +++ b/server/src/router/v1/auth/auth.routes.js @@ -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'] diff --git a/server/src/router/v1/auth/invite.controller.js b/server/src/router/v1/auth/invite.controller.js new file mode 100644 index 0000000..33c0f6e --- /dev/null +++ b/server/src/router/v1/auth/invite.controller.js @@ -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 } diff --git a/server/src/router/v1/player/player.routes.js b/server/src/router/v1/player/player.routes.js index 80da8b7..6999c12 100644 --- a/server/src/router/v1/player/player.routes.js +++ b/server/src/router/v1/player/player.routes.js @@ -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'] diff --git a/server/src/router/v1/player/shard.controller.js b/server/src/router/v1/player/shard.controller.js index 76927d7..e609524 100644 --- a/server/src/router/v1/player/shard.controller.js +++ b/server/src/router/v1/player/shard.controller.js @@ -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 } diff --git a/server/src/utils/mailer.js b/server/src/utils/mailer.js index 31e1dfe..1ff7260 100644 --- a/server/src/utils/mailer.js +++ b/server/src/utils/mailer.js @@ -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 } diff --git a/server/src/utils/shardIngest.js b/server/src/utils/shardIngest.js index c537467..f27b6a1 100644 --- a/server/src/utils/shardIngest.js +++ b/server/src/utils/shardIngest.js @@ -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, diff --git a/server/src/utils/uoLinkClient.js b/server/src/utils/uoLinkClient.js index ff3ef66..318dbee 100644 --- a/server/src/utils/uoLinkClient.js +++ b/server/src/utils/uoLinkClient.js @@ -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, diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index fa3fbee..2c74635 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -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": [ diff --git a/server/test/invites.test.js b/server/test/invites.test.js new file mode 100644 index 0000000..3c9108d --- /dev/null +++ b/server/test/invites.test.js @@ -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) +}) diff --git a/server/test/shardIngest.protocol2.test.js b/server/test/shardIngest.protocol2.test.js index ae44452..27617a8 100644 --- a/server/test/shardIngest.protocol2.test.js +++ b/server/test/shardIngest.protocol2.test.js @@ -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) +})