diff --git a/api/.env.example b/api/.env.example index 1d568703..3fd64303 100644 --- a/api/.env.example +++ b/api/.env.example @@ -2,3 +2,10 @@ PORT=3001 DATABASE_URL=postgresql://postgres:postgres@localhost:5432/aoweb TOKEN_AUTH=changeme CORS_ORIGIN=http://localhost:3000 + +# Game Data Admin (modo construccion / world builder) — issue #4 +# Email y/o account UUID del superadmin de game-data. +GAME_DATA_ADMIN_EMAIL=admin@local.test +GAME_DATA_ADMIN_ACCOUNT_ID= +# Token que el proxy de Next envia en header x-game-data-admin-token. +GAME_DATA_ADMIN_PROXY_TOKEN=admin-proxy-token diff --git a/api/schema.sql b/api/schema.sql index d0008678..c31caec9 100644 --- a/api/schema.sql +++ b/api/schema.sql @@ -652,3 +652,31 @@ CREATE INDEX IF NOT EXISTS idx_game_map_tile_entities_map ON game_map_tile_entities(map_num, status); CREATE INDEX IF NOT EXISTS idx_game_uploaded_graphics_created_at ON game_uploaded_graphics(created_at DESC); + +-- Permisos granulares de edicion de mapa por cuenta (#4). +-- map_num = 0: permiso global sobre mapas NO protegidos. +CREATE TABLE IF NOT EXISTS game_map_permissions ( + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + map_num INTEGER NOT NULL CHECK (map_num >= 0), + granted_by UUID REFERENCES accounts(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (account_id, map_num) +); + +CREATE INDEX IF NOT EXISTS idx_game_map_permissions_account_map + ON game_map_permissions(account_id, map_num); + +-- Bitacora de mutaciones de mapa (#4 atribucion quien/que/cuando). +CREATE TABLE IF NOT EXISTS game_map_mutation_log ( + id BIGSERIAL PRIMARY KEY, + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + map_num INTEGER NOT NULL CHECK (map_num > 0), + kind TEXT NOT NULL, + detail JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_game_map_mutation_log_map + ON game_map_mutation_log(map_num, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_game_map_mutation_log_account + ON game_map_mutation_log(account_id, created_at DESC); diff --git a/api/src/lib/mapEditPermissions.ts b/api/src/lib/mapEditPermissions.ts new file mode 100644 index 00000000..964937d2 --- /dev/null +++ b/api/src/lib/mapEditPermissions.ts @@ -0,0 +1,74 @@ +/** + * Etapa 0 (#4): decision pura de permisos de edicion de mapas. + * Sin I/O — testeable sin Postgres. + */ + +/** Ciudades principales (issue #4). Colaboradores nunca; admin solo con override. */ +export const PROTECTED_MAPS: ReadonlySet = new Set([1, 34, 59, 150]); + +export function isProtectedMap(mapNum: number): boolean { + return PROTECTED_MAPS.has(mapNum); +} + +export type MapEditDecision = + | { allowed: true } + | { allowed: false; reason: string; code: "forbidden" | "protected" }; + +/** + * Reglas: + * - Mapa protegido: solo superadmin + overrideProtected. + * - Superadmin: puede editar cualquier mapa no protegido. + * - Colaborador: solo mapas en grantedMapNums (o map_num=0 = todos no protegidos). + */ +export function evaluateMapEditPermission(input: { + accountId: string; + mapNum: number; + isSuperAdmin: boolean; + overrideProtected: boolean; + grantedMapNums: number[]; +}): MapEditDecision { + const { accountId, mapNum, isSuperAdmin, overrideProtected, grantedMapNums } = + input; + + if (!Number.isInteger(mapNum) || mapNum <= 0) { + return { + allowed: false, + reason: `Numero de mapa invalido: ${mapNum}.`, + code: "forbidden", + }; + } + + if (isProtectedMap(mapNum)) { + if (isSuperAdmin && overrideProtected) { + return { allowed: true }; + } + return { + allowed: false, + reason: isSuperAdmin + ? `El mapa ${mapNum} esta protegido. Envia header x-protected-map-override: true para forzar la edicion.` + : `El mapa ${mapNum} esta protegido. Los colaboradores no pueden editarlo.`, + code: "protected", + }; + } + + if (isSuperAdmin) { + return { allowed: true }; + } + + const granted = new Set(grantedMapNums); + if (granted.has(mapNum) || granted.has(0)) { + return { allowed: true }; + } + + return { + allowed: false, + reason: `La cuenta ${accountId} no tiene permisos para editar el mapa ${mapNum}.`, + code: "forbidden", + }; +} + +export function parseProtectedOverride( + headerValue: string | undefined, +): boolean { + return (headerValue ?? "").trim().toLowerCase() === "true"; +} diff --git a/api/src/repositories/mapEditPermissions.ts b/api/src/repositories/mapEditPermissions.ts new file mode 100644 index 00000000..18255b69 --- /dev/null +++ b/api/src/repositories/mapEditPermissions.ts @@ -0,0 +1,103 @@ +/** + * Persistencia de permisos por mapa + bitacora de atribucion (#4). + */ + +import pool from "../db"; +import { + evaluateMapEditPermission, + isProtectedMap, + type MapEditDecision, + PROTECTED_MAPS, +} from "../lib/mapEditPermissions"; + +export { PROTECTED_MAPS, isProtectedMap, evaluateMapEditPermission }; + +export async function listGrantedMapNums(accountId: string): Promise { + const result = await pool.query<{ map_num: number }>( + `SELECT map_num FROM game_map_permissions + WHERE account_id = $1 + ORDER BY map_num ASC`, + [accountId], + ); + return result.rows.map((row) => Number(row.map_num)); +} + +export async function checkMapEditPermission(options: { + accountId: string; + isSuperAdmin: boolean; + mapNum: number; + overrideProtected?: boolean; +}): Promise { + const grantedMapNums = options.isSuperAdmin + ? [] + : await listGrantedMapNums(options.accountId); + + return evaluateMapEditPermission({ + accountId: options.accountId, + mapNum: options.mapNum, + isSuperAdmin: options.isSuperAdmin, + overrideProtected: Boolean(options.overrideProtected), + grantedMapNums, + }); +} + +export async function grantMapPermission( + accountId: string, + mapNum: number, + grantedByAccountId: string, +): Promise { + if (!Number.isInteger(mapNum) || mapNum < 0) { + throw new Error("map_num invalido (usa >=1 o 0 para global no protegido)."); + } + + await pool.query( + `INSERT INTO game_map_permissions (account_id, map_num, granted_by, created_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (account_id, map_num) DO UPDATE + SET granted_by = EXCLUDED.granted_by, + created_at = NOW()`, + [accountId, mapNum, grantedByAccountId], + ); +} + +export async function revokeMapPermission( + accountId: string, + mapNum: number, +): Promise { + const result = await pool.query( + `DELETE FROM game_map_permissions WHERE account_id = $1 AND map_num = $2`, + [accountId, mapNum], + ); + return (result.rowCount ?? 0) > 0; +} + +export type MapMutationKind = + | "paint_tiles" + | "clear_tile" + | "place_entity" + | "remove_entity" + | "publish" + | "discard" + | "revert" + | "grant_permission" + | "revoke_permission"; + +/** Bitacora append-only: quien / que / cuando (aceptacion #4). */ +export async function recordMapMutation(input: { + accountId: string; + mapNum: number; + kind: MapMutationKind; + detail?: Record; +}): Promise { + await pool.query( + `INSERT INTO game_map_mutation_log + (account_id, map_num, kind, detail, created_at) + VALUES ($1, $2, $3, $4::jsonb, NOW())`, + [ + input.accountId, + input.mapNum, + input.kind, + JSON.stringify(input.detail ?? {}), + ], + ); +} diff --git a/api/src/server.ts b/api/src/server.ts index 2b309610..9957a862 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -113,6 +113,14 @@ import { tileEntitySchema, uploadGraphic, } from "./repositories/worldBuilder"; +import { + checkMapEditPermission, + grantMapPermission, + listGrantedMapNums, + recordMapMutation, + revokeMapPermission, +} from "./repositories/mapEditPermissions"; +import { parseProtectedOverride } from "./lib/mapEditPermissions"; import { MAX_PNG_BYTES } from "./lib/pngValidation"; import { getGameCraftingRecipeById, @@ -238,6 +246,62 @@ async function requireAdminEmailSession( return authorized; } + +/** + * Sesion autorizada para MUTAR un mapa (#4). + * - Requiere proxy token (mismo mecanismo GAME_DATA_ADMIN_*). + * - Superadmin: email/account de game-data admin. + * - Colaborador: fila en game_map_permissions para ese mapa (o map_num=0). + * - Mapas protegidos: solo superadmin + header x-protected-map-override: true. + */ +async function requireMapEditSession( + request: express.Request, + response: express.Response, + mapNum: number, +): Promise<{ accountId: string; isSuperAdmin: boolean } | null> { + if (!config.gameDataAdminProxyToken) { + response + .status(403) + .json({ error: "Admin de game-data deshabilitado." }); + return null; + } + + if ( + getGameDataAdminProxyHeader(request) !== config.gameDataAdminProxyToken + ) { + response.status(403).json({ error: "No autorizado." }); + return null; + } + + const authorized = await getAuthorizedSession(request); + + if (!authorized) { + response.status(401).json({ error: "Unauthorized" }); + return null; + } + + const isSuperAdmin = isAuthorizedGameDataAdmin(authorized.session); + const overrideProtected = parseProtectedOverride( + request.header("x-protected-map-override") ?? undefined, + ); + const permission = await checkMapEditPermission({ + accountId: authorized.session.account._id, + isSuperAdmin, + mapNum, + overrideProtected, + }); + + if (!permission.allowed) { + response.status(403).json({ error: permission.reason }); + return null; + } + + return { + accountId: authorized.session.account._id, + isSuperAdmin, + }; +} + async function ensurePgStatStatements(): Promise { try { await pool.query("CREATE EXTENSION IF NOT EXISTS pg_stat_statements"); @@ -853,9 +917,6 @@ app.get("/game-data/graphics/:grhIndex.png", async (request, response) => { app.put("/admin/game-data/maps/:mapNum/tiles", async (request, response) => { try { - const authorized = await requireAdminEmailSession(request, response); - if (!authorized) return; - const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); if (!Number.isInteger(mapNum) || mapNum <= 0) { @@ -863,6 +924,9 @@ app.put("/admin/game-data/maps/:mapNum/tiles", async (request, response) => { return; } + const authorized = await requireMapEditSession(request, response, mapNum); + if (!authorized) return; + const parsed = paintTilesSchema.safeParse(request.body); if (!parsed.success) { @@ -872,13 +936,18 @@ app.put("/admin/game-data/maps/:mapNum/tiles", async (request, response) => { return; } - response.json( - await paintTiles( - mapNum, - parsed.data.tiles, - authorized.session.account._id, - ), + const result = await paintTiles( + mapNum, + parsed.data.tiles, + authorized.accountId, ); + await recordMapMutation({ + accountId: authorized.accountId, + mapNum, + kind: "paint_tiles", + detail: { applied: result.applied }, + }); + response.json(result); } catch (error) { const message = error instanceof Error ? error.message : "Unexpected error"; @@ -890,12 +959,6 @@ app.delete( "/admin/game-data/maps/:mapNum/tiles/:x/:y/:layer", async (request, response) => { try { - const authorized = await requireAdminEmailSession( - request, - response, - ); - if (!authorized) return; - const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); const x = Number.parseInt(request.params.x ?? "", 10); const y = Number.parseInt(request.params.y ?? "", 10); @@ -906,7 +969,21 @@ app.delete( return; } - response.json({ removed: await clearTile(mapNum, x, y, layer) }); + const authorized = await requireMapEditSession( + request, + response, + mapNum, + ); + if (!authorized) return; + + const removed = await clearTile(mapNum, x, y, layer); + await recordMapMutation({ + accountId: authorized.accountId, + mapNum, + kind: "clear_tile", + detail: { x, y, layer, removed }, + }); + response.json({ removed }); } catch (error) { const message = error instanceof Error ? error.message : "Unexpected error"; @@ -1016,9 +1093,6 @@ app.get("/admin/game-data/session", async (request, response) => { /** Publica los borradores de un mapa. A partir de aca los ven los jugadores. */ app.post("/admin/game-data/maps/:mapNum/publish", async (request, response) => { try { - const authorized = await requireAdminEmailSession(request, response); - if (!authorized) return; - const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); if (!Number.isInteger(mapNum) || mapNum <= 0) { @@ -1026,9 +1100,17 @@ app.post("/admin/game-data/maps/:mapNum/publish", async (request, response) => { return; } - response.json( - await publishMap(mapNum, authorized.session.account._id), - ); + const authorized = await requireMapEditSession(request, response, mapNum); + if (!authorized) return; + + const result = await publishMap(mapNum, authorized.accountId); + await recordMapMutation({ + accountId: authorized.accountId, + mapNum, + kind: "publish", + detail: result, + }); + response.json(result); } catch (error) { const message = error instanceof Error ? error.message : "Unexpected error"; @@ -1039,9 +1121,6 @@ app.post("/admin/game-data/maps/:mapNum/publish", async (request, response) => { /** Descarta los borradores sin tocar lo ya publicado. */ app.post("/admin/game-data/maps/:mapNum/discard", async (request, response) => { try { - const authorized = await requireAdminEmailSession(request, response); - if (!authorized) return; - const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); if (!Number.isInteger(mapNum) || mapNum <= 0) { @@ -1049,7 +1128,17 @@ app.post("/admin/game-data/maps/:mapNum/discard", async (request, response) => { return; } - response.json(await discardDrafts(mapNum)); + const authorized = await requireMapEditSession(request, response, mapNum); + if (!authorized) return; + + const result = await discardDrafts(mapNum); + await recordMapMutation({ + accountId: authorized.accountId, + mapNum, + kind: "discard", + detail: result, + }); + response.json(result); } catch (error) { const message = error instanceof Error ? error.message : "Unexpected error"; @@ -1063,9 +1152,6 @@ app.post("/admin/game-data/maps/:mapNum/discard", async (request, response) => { */ app.post("/admin/game-data/maps/:mapNum/revert", async (request, response) => { try { - const authorized = await requireAdminEmailSession(request, response); - if (!authorized) return; - const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); if (!Number.isInteger(mapNum) || mapNum <= 0) { @@ -1073,7 +1159,17 @@ app.post("/admin/game-data/maps/:mapNum/revert", async (request, response) => { return; } - response.json(await revertMap(mapNum)); + const authorized = await requireMapEditSession(request, response, mapNum); + if (!authorized) return; + + const result = await revertMap(mapNum); + await recordMapMutation({ + accountId: authorized.accountId, + mapNum, + kind: "revert", + detail: result, + }); + response.json(result); } catch (error) { const message = error instanceof Error ? error.message : "Unexpected error"; @@ -1139,12 +1235,6 @@ app.put( "/admin/game-data/maps/:mapNum/entities", async (request, response) => { try { - const authorized = await requireAdminEmailSession( - request, - response, - ); - if (!authorized) return; - const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); if (!Number.isInteger(mapNum) || mapNum <= 0) { @@ -1152,6 +1242,13 @@ app.put( return; } + const authorized = await requireMapEditSession( + request, + response, + mapNum, + ); + if (!authorized) return; + const parsed = tileEntitySchema.safeParse(request.body); if (!parsed.success) { @@ -1161,13 +1258,18 @@ app.put( return; } - response.json( - await placeTileEntity( - mapNum, - parsed.data, - authorized.session.account._id, - ), + const result = await placeTileEntity( + mapNum, + parsed.data, + authorized.accountId, ); + await recordMapMutation({ + accountId: authorized.accountId, + mapNum, + kind: "place_entity", + detail: parsed.data, + }); + response.json(result); } catch (error) { const message = error instanceof Error ? error.message : "Unexpected error"; @@ -1181,12 +1283,6 @@ app.delete( "/admin/game-data/maps/:mapNum/entities/:x/:y/:kind", async (request, response) => { try { - const authorized = await requireAdminEmailSession( - request, - response, - ); - if (!authorized) return; - const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); const x = Number.parseInt(request.params.x ?? "", 10); const y = Number.parseInt(request.params.y ?? "", 10); @@ -1202,9 +1298,120 @@ app.delete( return; } + const authorized = await requireMapEditSession( + request, + response, + mapNum, + ); + if (!authorized) return; + + const removed = await removeTileEntity(mapNum, x, y, kind); + await recordMapMutation({ + accountId: authorized.accountId, + mapNum, + kind: "remove_entity", + detail: { x, y, entityKind: kind, removed }, + }); + response.json({ + removed, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } + }, +); + + +/** Lista permisos de mapa de una cuenta (superadmin). */ +app.get( + "/admin/game-data/accounts/:accountId/map-permissions", + async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const accountId = request.params.accountId ?? ""; + if (!accountId) { + response.status(400).json({ error: "accountId invalido." }); + return; + } + response.json({ - removed: await removeTileEntity(mapNum, x, y, kind), + accountId, + mapNums: await listGrantedMapNums(accountId), + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } + }, +); + +/** Concede permiso de edicion sobre un mapa (o map_num=0 global no protegido). */ +app.put( + "/admin/game-data/maps/:mapNum/permissions/:accountId", + async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); + const accountId = request.params.accountId ?? ""; + + if (!Number.isInteger(mapNum) || mapNum < 0 || !accountId) { + response.status(400).json({ error: "Parametros invalidos." }); + return; + } + + await grantMapPermission( + accountId, + mapNum, + authorized.session.account._id, + ); + await recordMapMutation({ + accountId: authorized.session.account._id, + mapNum: mapNum === 0 ? 1 : mapNum, + kind: "grant_permission", + detail: { targetAccountId: accountId, mapNum }, }); + response.json({ accountId, mapNum, granted: true }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } + }, +); + +/** Revoca permiso de edicion de mapa. */ +app.delete( + "/admin/game-data/maps/:mapNum/permissions/:accountId", + async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); + const accountId = request.params.accountId ?? ""; + + if (!Number.isInteger(mapNum) || mapNum < 0 || !accountId) { + response.status(400).json({ error: "Parametros invalidos." }); + return; + } + + const revoked = await revokeMapPermission(accountId, mapNum); + if (revoked) { + await recordMapMutation({ + accountId: authorized.session.account._id, + mapNum: mapNum === 0 ? 1 : mapNum, + kind: "revoke_permission", + detail: { targetAccountId: accountId, mapNum }, + }); + } + response.json({ accountId, mapNum, revoked }); } catch (error) { const message = error instanceof Error ? error.message : "Unexpected error"; diff --git a/api/src/tests/mapEditPermissions.test.ts b/api/src/tests/mapEditPermissions.test.ts new file mode 100644 index 00000000..95d82765 --- /dev/null +++ b/api/src/tests/mapEditPermissions.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + PROTECTED_MAPS, + evaluateMapEditPermission, + isProtectedMap, + parseProtectedOverride, +} from "../lib/mapEditPermissions"; + +test("PROTECTED_MAPS covers main cities from issue #4", () => { + assert.ok(isProtectedMap(1)); + assert.ok(isProtectedMap(34)); + assert.ok(isProtectedMap(59)); + assert.ok(isProtectedMap(150)); + assert.equal(isProtectedMap(50), false); + assert.ok(PROTECTED_MAPS.size >= 4); +}); + +test("account without grant is forbidden on a normal map", () => { + const result = evaluateMapEditPermission({ + accountId: "collab", + mapNum: 50, + isSuperAdmin: false, + overrideProtected: false, + grantedMapNums: [], + }); + assert.equal(result.allowed, false); + if (!result.allowed) { + assert.equal(result.code, "forbidden"); + assert.match(result.reason, /no tiene permisos/); + } +}); + +test("grant on map 50 does not allow editing map 1 (protected) or map 2", () => { + const on2 = evaluateMapEditPermission({ + accountId: "collab", + mapNum: 2, + isSuperAdmin: false, + overrideProtected: false, + grantedMapNums: [50], + }); + assert.equal(on2.allowed, false); + + const on50 = evaluateMapEditPermission({ + accountId: "collab", + mapNum: 50, + isSuperAdmin: false, + overrideProtected: false, + grantedMapNums: [50], + }); + assert.equal(on50.allowed, true); + + const on1 = evaluateMapEditPermission({ + accountId: "collab", + mapNum: 1, + isSuperAdmin: false, + overrideProtected: true, + grantedMapNums: [50, 1], + }); + assert.equal(on1.allowed, false); + if (!on1.allowed) { + assert.equal(on1.code, "protected"); + } +}); + +test("superadmin needs explicit override for protected maps", () => { + const blocked = evaluateMapEditPermission({ + accountId: "admin", + mapNum: 1, + isSuperAdmin: true, + overrideProtected: false, + grantedMapNums: [], + }); + assert.equal(blocked.allowed, false); + if (!blocked.allowed) { + assert.equal(blocked.code, "protected"); + assert.match(blocked.reason, /x-protected-map-override/); + } + + const allowed = evaluateMapEditPermission({ + accountId: "admin", + mapNum: 1, + isSuperAdmin: true, + overrideProtected: true, + grantedMapNums: [], + }); + assert.equal(allowed.allowed, true); + + const free = evaluateMapEditPermission({ + accountId: "admin", + mapNum: 50, + isSuperAdmin: true, + overrideProtected: false, + grantedMapNums: [], + }); + assert.equal(free.allowed, true); +}); + +test("map_num 0 grant covers all non-protected maps", () => { + const ok = evaluateMapEditPermission({ + accountId: "collab", + mapNum: 77, + isSuperAdmin: false, + overrideProtected: false, + grantedMapNums: [0], + }); + assert.equal(ok.allowed, true); + + const protectedStillBlocked = evaluateMapEditPermission({ + accountId: "collab", + mapNum: 34, + isSuperAdmin: false, + overrideProtected: true, + grantedMapNums: [0], + }); + assert.equal(protectedStillBlocked.allowed, false); +}); + +test("parseProtectedOverride only accepts true", () => { + assert.equal(parseProtectedOverride("true"), true); + assert.equal(parseProtectedOverride("TRUE"), true); + assert.equal(parseProtectedOverride("1"), false); + assert.equal(parseProtectedOverride(undefined), false); +});