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:
2026-07-11 02:13:46 -05:00
parent 523113f013
commit 064f02c4b6
7 changed files with 655 additions and 0 deletions

View 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 }

View 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 }