diff --git a/server/db/schema.sql b/server/db/schema.sql index 992f43b..e2c61c4 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -368,6 +368,21 @@ CREATE TABLE IF NOT EXISTS shard_houses ( INDEX idx_shard_houses_idoc (is_idoc) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Site-side mirror of in-game-account → website-user links. The sidecar is the +-- source of truth (it tags the game account with the websiteUserId on +-- /link/confirm); this table mirrors it so the player portal can list a user's +-- linked accounts and enforce ownership on roster/vendor reads without a shard +-- round-trip. account is unique (one game account maps to at most one site user); +-- a single user may link several game accounts. +CREATE TABLE IF NOT EXISTS shard_account_links ( + account VARCHAR(120) NOT NULL PRIMARY KEY, + user_id INT NOT NULL, + char_name VARCHAR(120) NULL, + linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_shard_links_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_shard_links_user (user_id) +) 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 diff --git a/server/src/model/shardLinks/shardLinks.db.js b/server/src/model/shardLinks/shardLinks.db.js new file mode 100644 index 0000000..915b53d --- /dev/null +++ b/server/src/model/shardLinks/shardLinks.db.js @@ -0,0 +1,36 @@ +const { query } = require('../../utils/db') + +const COLS = 'account, user_id, char_name, linked_at' + +// Upsert a link. account is the PK, so a re-link moves the account to the new +// user (the sidecar already treats /link/confirm as authoritative). +async function upsert({ account, userId, charName }) { + await query( + `INSERT INTO shard_account_links (account, user_id, char_name) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), char_name = VALUES(char_name)`, + [account, userId, charName || null], + ) + return getByAccount(account) +} + +async function getByAccount(account) { + const rows = await query(`SELECT ${COLS} FROM shard_account_links WHERE account = ? LIMIT 1`, [account]) + return rows[0] || null +} + +const listByUser = (userId) => + query(`SELECT ${COLS} FROM shard_account_links WHERE user_id = ? ORDER BY linked_at DESC`, [userId]) + +async function isOwnedBy(account, userId) { + const rows = await query( + 'SELECT 1 FROM shard_account_links WHERE account = ? AND user_id = ? LIMIT 1', + [account, userId], + ) + return rows.length > 0 +} + +const remove = (account, userId) => + query('DELETE FROM shard_account_links WHERE account = ? AND user_id = ?', [account, userId]) + +module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove } diff --git a/server/src/model/shardLinks/shardLinks.model.js b/server/src/model/shardLinks/shardLinks.model.js new file mode 100644 index 0000000..9e1c7fd --- /dev/null +++ b/server/src/model/shardLinks/shardLinks.model.js @@ -0,0 +1,34 @@ +// Site-side mirror of in-game-account → website-user links. The sidecar owns the +// authoritative link (it tags the game account on /link/confirm); this model +// records it locally so the player portal can list links and enforce ownership. + +const db = require('./shardLinks.db') + +function toSafe(row) { + if (!row) return null + return { + account: row.account, + userId: row.user_id, + charName: row.char_name || null, + linkedAt: row.linked_at, + } +} + +async function link({ account, userId, charName }) { + return toSafe(await db.upsert({ account, userId, charName })) +} + +async function listForUser(userId) { + const rows = await db.listByUser(userId) + return rows.map(toSafe) +} + +const ownsAccount = (account, userId) => db.isOwnedBy(account, userId) + +async function getByAccount(account) { + return toSafe(await db.getByAccount(account)) +} + +const unlink = (account, userId) => db.remove(account, userId) + +module.exports = { link, listForUser, ownsAccount, getByAccount, unlink } diff --git a/server/src/router/v1/player/player.routes.js b/server/src/router/v1/player/player.routes.js index 4cfce19..7af0cdb 100644 --- a/server/src/router/v1/player/player.routes.js +++ b/server/src/router/v1/player/player.routes.js @@ -10,6 +10,7 @@ const express = require('express') const { body, param } = require('express-validator') const account = require('../admin/account.controller') +const shard = require('./shard.controller') const { requireAuth, requireRole } = require('../../../auth/session.middleware') const noindex = require('../../../middleware/noindex') const validate = require('../../../middleware/validate') @@ -129,4 +130,57 @@ playerRouter.delete( account.unlinkIdentity, ) +// ── Game account linking (uo-link) ───────────────────────────────────────── +// Link an in-game account with a one-time code from [link, then read the +// account's roster / vendors (ownership-checked against the local link mirror). +const ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/ +playerRouter.post( + '/shard/link', + // #swagger.tags = ['Player · Shard'] + // #swagger.summary = 'Link an in-game account with a one-time code' + // #swagger.description = 'The player runs [link in game to get a code, then submits it here. The server confirms it with the sidecar and mirrors the link.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */ + /* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + body('code').isString().trim().isLength({ min: 4, max: 32 }), + validate, + shard.link, +) +playerRouter.get( + '/shard/accounts', + // #swagger.tags = ['Player · Shard'] + // #swagger.summary = 'List the caller’s linked game accounts' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */ + shard.listAccounts, +) +playerRouter.get( + '/shard/roster/:account', + // #swagger.tags = ['Player · Shard'] + // #swagger.summary = 'Character roster for a linked account' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' } + /* #swagger.responses[200] = { description: 'Account roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('account').matches(ACCOUNT_RE), + validate, + shard.roster, +) +playerRouter.get( + '/shard/vendors/:account', + // #swagger.tags = ['Player · Shard'] + // #swagger.summary = 'Player vendors for a linked account' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' } + /* #swagger.responses[200] = { description: 'Vendor snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('account').matches(ACCOUNT_RE), + validate, + shard.vendors, +) + module.exports = playerRouter diff --git a/server/src/router/v1/player/shard.controller.js b/server/src/router/v1/player/shard.controller.js new file mode 100644 index 0000000..c6c230f --- /dev/null +++ b/server/src/router/v1/player/shard.controller.js @@ -0,0 +1,81 @@ +// ── Player: game-account linking + reads ─────────────────────────────────── +// +// The player-facing surface for the uo-link integration. A logged-in player +// runs [link in game, gets a one-time code, and enters it here — the server +// confirms it with the sidecar (which permanently tags the game account with the +// website user id) and mirrors the link locally. Roster/vendor reads are +// ownership-checked against that mirror so a player can only see accounts they +// have linked. The sidecar token stays server-side throughout. + +const uoLinkClient = require('../../../utils/uoLinkClient') +const shardLinks = require('../../../model/shardLinks/shardLinks.model') +const activity = require('../../../model/activity/activity.model') + +const log = require('../../../utils/logger')('player-shard') + +// POST /player/shard/link — confirm an in-game link code. +async function link(req, res) { + const { code } = req.body + try { + const result = await uoLinkClient.confirmLink(code, req.user.id) + + if (result.ok && result.data && result.data.kind === 'link.ok') { + const account = result.data.account + await shardLinks.link({ account, userId: req.user.id, charName: result.data.char || null }) + await activity.log({ req, action: 'uoLink.account.link', detail: { account } }) + log.info('player linked game account', { user: req.user.username, account }) + return res.json({ linked: true, account }) + } + + // Sidecar reports bad/expired codes as 400 link.error or 404. + if (result.status === 400 || result.status === 404) { + return res.status(400).json({ message: 'That code is unknown or has expired. Run [link in game for a new one.' }) + } + if (result.status === 503 || result.status === 0) { + return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' }) + } + return res.status(502).json({ message: 'Could not confirm the link with the shard.' }) + } catch (err) { + log.error('player.shard.link', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /player/shard/accounts — the caller's linked game accounts. +async function listAccounts(req, res) { + try { + return res.json(await shardLinks.listForUser(req.user.id)) + } catch (err) { + log.error('player.shard.listAccounts', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// Shared ownership gate + live round-trip for roster/vendors. `fetcher` is the +// uoLinkClient method to call with the account. +async function ownedRoundTrip(req, res, fetcher, label) { + const { account } = req.params + try { + const owns = await shardLinks.ownsAccount(account, req.user.id) + if (!owns) return res.status(403).json({ message: 'That account is not linked to your profile.' }) + + const result = await fetcher(account) + if (result.ok) return res.json(result.data) + if (result.status === 404) return res.status(404).json({ message: 'Not found.' }) + if (result.status === 503 || result.status === 0) { + return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' }) + } + return res.status(502).json({ message: 'Could not reach the shard.' }) + } catch (err) { + log.error(`player.shard.${label}`, err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /player/shard/roster/:account — characters on a linked account. +const roster = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getRoster, 'roster') + +// GET /player/shard/vendors/:account — player vendors on a linked account. +const vendors = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getVendors, 'vendors') + +module.exports = { link, listAccounts, roster, vendors } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 801145f..918fbc3 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -48,6 +48,10 @@ "name": "Player", "description": "Self-service player accounts (register, credentials, 2FA, linked identities)" }, + { + "name": "Player · Shard", + "description": "Link an in-game account and read its roster / vendors (uo-link)" + }, { "name": "Admin · Dashboard", "description": "Dashboard summary and site mode" @@ -6250,6 +6254,258 @@ } ] } + }, + "/api/v1/player/shard/link": { + "post": { + "tags": [ + "Player · Shard" + ], + "summary": "Link an in-game account with a one-time code", + "description": "The player runs [link in game to get a code, then submits it here. The server confirms it with the sidecar and mirrors the link.", + "responses": { + "200": { + "description": "Linked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShardLinkResult" + } + } + } + }, + "400": { + "description": "Unknown or expired code", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + }, + "502": { + "description": "Bad Gateway" + }, + "503": { + "description": "Shard unavailable — retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShardLinkRequest" + } + } + } + } + } + }, + "/api/v1/player/shard/accounts": { + "get": { + "tags": [ + "Player · Shard" + ], + "summary": "List the caller’s linked game accounts", + "description": "", + "responses": { + "200": { + "description": "Linked accounts", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardLink" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/shard/roster/{account}": { + "get": { + "tags": [ + "Player · Shard" + ], + "summary": "Character roster for a linked account", + "description": "", + "parameters": [ + { + "name": "account", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "A game account linked to the caller." + } + ], + "responses": { + "200": { + "description": "Account roster", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Account not linked to the caller", + "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": [] + } + ] + } + }, + "/api/v1/player/shard/vendors/{account}": { + "get": { + "tags": [ + "Player · Shard" + ], + "summary": "Player vendors for a linked account", + "description": "", + "parameters": [ + { + "name": "account", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "A game account linked to the caller." + } + ], + "responses": { + "200": { + "description": "Vendor snapshot", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Account not linked to the caller", + "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": [] + } + ] + } } }, "components": { @@ -9998,6 +10254,160 @@ } } } + }, + "ShardLinkRequest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "required": { + "type": "array", + "example": [ + "code" + ], + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "properties": { + "code": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "The one-time code shown by [link in game." + }, + "example": { + "type": "string", + "example": "AB12CD" + } + } + } + } + } + } + }, + "ShardLinkResult": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "linked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "account": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "whitlocktech" + } + } + } + } + } + } + }, + "ShardLink": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A linked in-game account (GET /player/shard/accounts)." + }, + "properties": { + "type": "object", + "properties": { + "account": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "whitlocktech" + } + } + }, + "userId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 42 + } + } + }, + "charName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Darrow" + } + } + }, + "linkedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } } } } diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index 6df598f..7b407e7 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -49,6 +49,7 @@ const doc = { { name: 'Public · Shard', description: 'Live shard data ingested from the uo-link sidecar (status, feed, economy, IDOC, characters)' }, { name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' }, { name: 'Player', description: 'Self-service player accounts (register, credentials, 2FA, linked identities)' }, + { name: 'Player · Shard', description: 'Link an in-game account and read its roster / vendors (uo-link)' }, { name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' }, { name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' }, { name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' }, @@ -562,6 +563,30 @@ const doc = { updatedAt: { type: 'string', format: 'date-time' }, }, }, + ShardLinkRequest: { + type: 'object', + required: ['code'], + properties: { + code: { type: 'string', description: 'The one-time code shown by [link in game.', example: 'AB12CD' }, + }, + }, + ShardLinkResult: { + type: 'object', + properties: { + linked: { type: 'boolean', example: true }, + account: { type: 'string', example: 'whitlocktech' }, + }, + }, + ShardLink: { + type: 'object', + description: 'A linked in-game account (GET /player/shard/accounts).', + properties: { + account: { type: 'string', example: 'whitlocktech' }, + userId: { type: 'integer', example: 42 }, + charName: { type: 'string', nullable: true, example: 'Darrow' }, + linkedAt: { type: 'string', format: 'date-time' }, + }, + }, }, }, }