Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
28 changes: 28 additions & 0 deletions api/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);
74 changes: 74 additions & 0 deletions api/src/lib/mapEditPermissions.ts
Original file line number Diff line number Diff line change
@@ -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<number> = 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";
}
103 changes: 103 additions & 0 deletions api/src/repositories/mapEditPermissions.ts
Original file line number Diff line number Diff line change
@@ -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<number[]> {
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<MapEditDecision> {
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<void> {
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<boolean> {
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<string, unknown>;
}): Promise<void> {
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 ?? {}),
],
);
}
Loading