feat(houses): tier house visibility — public IDOC-only, staff full, player own

Per request, split the single public house registry into three role-scoped views:

- Public /site/houses → only houses in DANGER (IDOC), by LOCATION (region + map/
  coords). No owner, price, co-owners or decay detail. Renamed "Houses in danger";
  kept live via the public house.decay feed. The full-registry deltas
  (house.update / house.remove — which carry owner/price) are REMOVED from the
  public SSE allowlist so they never reach the public channel.
- Staff full registry → new /admin/houses (admin + moderator, RoleGate + MOD_PATHS)
  backed by GET /admin/shard/houses (modAccess), with owner/price/co-owners/decay
  and search, kept live on the admin SSE channel.
- Player portal → "My houses" home-status section (own houses only, with decay/
  IDOC status) via GET /player/shard/houses, scoped to the caller's linked accounts.

Server tests green, client build clean, swagger regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 16:50:37 -05:00
parent a165c90c62
commit 1629796235
13 changed files with 362 additions and 115 deletions

View File

@@ -288,6 +288,16 @@ adminRouter.get(
modAccess,
shardOps.listAudit,
)
adminRouter.get(
'/shard/houses',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Full house registry — owner, price, decay (admin/moderator)'
// #swagger.description = 'The complete house registry. The public endpoint shows only IDOC houses with location; this staff view carries owner/price/co-owner/decay detail.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
modAccess,
shardOps.listHouses,
)
// ── Image uploads (screenshots/gallery) ───────────────────────────────
const UPLOAD_DIR =

View File

@@ -155,4 +155,17 @@ async function listAudit(req, res) {
}
}
module.exports = { kick, ban, unban, broadcast, listPages, respondPage, closePage, listAudit }
// GET /admin/shard/houses — the FULL house registry (owner, price, co-owners,
// decay), staff-only (modAccess). The public /public/shard/houses shows only IDOC
// houses with location; this is the complete board, kept live for staff on the
// admin SSE channel (house.update / house.remove).
async function listHouses(req, res) {
try {
return res.json(await shardState.listHouses())
} catch (err) {
log.error('shardOps.listHouses', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { kick, ban, unban, broadcast, listPages, respondPage, closePage, listAudit, listHouses }

View File

@@ -222,5 +222,14 @@ playerRouter.get(
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
shard.getSales,
)
playerRouter.get(
'/shard/houses',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'The callers own houses (home status)'
// #swagger.description = 'Houses owned by the callers linked accounts, with decay/IDOC status. Only the callers own houses — never anyone elses.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The callers houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
shard.getHouses,
)
module.exports = playerRouter

View File

@@ -148,6 +148,20 @@ async function getSales(req, res) {
}
}
// GET /player/shard/houses — the caller's OWN houses (home status), scoped to
// their linked accounts. A player sees their own decay/IDOC standing; never
// anyone else's. Full detail is fine here — it's their property.
async function getHouses(req, res) {
try {
const links = await shardLinks.listForUser(req.user.id)
const accounts = links.map((l) => l.account)
return res.json(await shardState.listHousesForAccounts(accounts))
} catch (err) {
log.error('player.shard.getHouses', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// 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) {
@@ -202,4 +216,4 @@ async function createGameAccount(req, res) {
}
}
module.exports = { link, listAccounts, roster, vendors, getChar, getSales, createGameAccount }
module.exports = { link, listAccounts, roster, vendors, getChar, getSales, getHouses, createGameAccount }

View File

@@ -148,11 +148,24 @@ async function getPresence(req, res) {
}
}
// GET /public/shard/houses — the house registry (every house we've seen via
// house.update). Live via house.update / house.remove on the public SSE stream.
// GET /public/shard/houses — PUBLIC view: only houses in danger (IDOC), and only
// their location (name + region + map/coords). Owner, price, co-owners and decay
// detail are staff-only (see admin GET /admin/shard/houses). Live via house.decay
// on the public SSE stream. This is the "where are the falling houses" board.
async function getHouses(req, res) {
try {
return res.json(await shardState.listHouses())
const idoc = await shardState.listIdoc()
const publicHouses = idoc.map((h) => ({
serial: h.serial,
name: h.name,
region: h.region,
map: h.map,
x: h.x,
y: h.y,
z: h.z,
isIdoc: true,
}))
return res.json(publicHouses)
} catch (err) {
log.error('shard.getHouses', err)
return res.status(500).json({ message: 'Internal Server Error' })

View File

@@ -36,15 +36,17 @@ const PUBLIC_KINDS = new Set([
// Champion-spawn board deltas — the public Champions page renders these live.
'champ.update',
'champ.remove',
// Protocol 2.0 boards — all public, rendered live on their respective pages.
// Protocol 2.0 boards — public, rendered live on their respective pages.
'guild.update',
'guild.remove',
'guild.join',
'city.update',
'presence.online',
'region.enter',
'house.update',
'house.remove',
// NOTE: house.update / house.remove (the full registry — owner, price, co-owners)
// are deliberately NOT public. The public Houses page shows only IDOC houses (via
// house.decay, which is public above) with location only; the full registry is
// staff-only and rides the admin SSE channel. See public/shard.controller getHouses.
])
// Open response streams per channel.

View File

@@ -2876,6 +2876,41 @@
]
}
},
"/api/v1/admin/shard/houses": {
"get": {
"tags": [
"Admin · Shard"
],
"summary": "Full house registry — owner, price, decay (admin/moderator)",
"description": "The complete house registry. The public endpoint shows only IDOC houses with location; this staff view carries owner/price/co-owner/decay detail.",
"responses": {
"200": {
"description": "Houses, ordered by name",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ShardHouse"
}
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/dashboard": {
"get": {
"tags": [
@@ -8721,6 +8756,47 @@
}
]
}
},
"/api/v1/player/shard/houses": {
"get": {
"tags": [
"Player · Shard"
],
"summary": "The callers own houses (home status)",
"description": "Houses owned by the callers linked accounts, with decay/IDOC status. Only the callers own houses — never anyone elses.",
"responses": {
"200": {
"description": "The callers houses",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ShardHouse"
}
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
}
},
"components": {