Add player account linking + roster/vendor reads (phase 3)
Ties an in-game account to a website user and gates reads on ownership.
- schema: shard_account_links (account PK → user_id, char_name, linked_at;
FK users ON DELETE CASCADE) — the site-side mirror of the sidecar's
authoritative link.
- model/shardLinks: upsert/list/ownership-check/getByAccount/unlink.
- player/shard.controller.js:
- POST /player/shard/link — confirm a one-time [link code via
uoLinkClient.confirmLink(code, req.user.id); on link.ok mirror the link and
activity.log it; bad/expired codes → 400, shard down → 503.
- GET /player/shard/accounts — the caller's linked accounts.
- GET /player/shard/roster/:account and /vendors/:account — live round-trips,
ownership-checked against the mirror (403 otherwise), 503 on shard restart.
- player.routes.js: mounted under the existing requireRole('player') gate with
express-validator guards + #swagger annotations; new "Player · Shard" tag and
ShardLinkRequest/ShardLinkResult/ShardLink schemas; spec regenerated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
This commit is contained in:
@@ -368,6 +368,21 @@ CREATE TABLE IF NOT EXISTS shard_houses (
|
|||||||
INDEX idx_shard_houses_idoc (is_idoc)
|
INDEX idx_shard_houses_idoc (is_idoc)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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
|
-- 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
|
||||||
|
|||||||
36
server/src/model/shardLinks/shardLinks.db.js
Normal file
36
server/src/model/shardLinks/shardLinks.db.js
Normal file
@@ -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 }
|
||||||
34
server/src/model/shardLinks/shardLinks.model.js
Normal file
34
server/src/model/shardLinks/shardLinks.model.js
Normal file
@@ -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 }
|
||||||
@@ -10,6 +10,7 @@ const express = require('express')
|
|||||||
const { body, param } = require('express-validator')
|
const { body, param } = require('express-validator')
|
||||||
|
|
||||||
const account = require('../admin/account.controller')
|
const account = require('../admin/account.controller')
|
||||||
|
const shard = require('./shard.controller')
|
||||||
const { requireAuth, requireRole } = require('../../../auth/session.middleware')
|
const { requireAuth, requireRole } = require('../../../auth/session.middleware')
|
||||||
const noindex = require('../../../middleware/noindex')
|
const noindex = require('../../../middleware/noindex')
|
||||||
const validate = require('../../../middleware/validate')
|
const validate = require('../../../middleware/validate')
|
||||||
@@ -129,4 +130,57 @@ playerRouter.delete(
|
|||||||
account.unlinkIdentity,
|
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
|
module.exports = playerRouter
|
||||||
|
|||||||
81
server/src/router/v1/player/shard.controller.js
Normal file
81
server/src/router/v1/player/shard.controller.js
Normal file
@@ -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 }
|
||||||
@@ -48,6 +48,10 @@
|
|||||||
"name": "Player",
|
"name": "Player",
|
||||||
"description": "Self-service player accounts (register, credentials, 2FA, linked identities)"
|
"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",
|
"name": "Admin · Dashboard",
|
||||||
"description": "Dashboard summary and site mode"
|
"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": {
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '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: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' },
|
||||||
{ name: 'Player', description: 'Self-service player accounts (register, credentials, 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 · Dashboard', description: 'Dashboard summary and site mode' },
|
||||||
{ name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' },
|
{ name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' },
|
||||||
{ name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' },
|
{ name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' },
|
||||||
@@ -562,6 +563,30 @@ const doc = {
|
|||||||
updatedAt: { type: 'string', format: 'date-time' },
|
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' },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user