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
15 changes: 15 additions & 0 deletions api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,18 @@ PORT=3001
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/aoweb
TOKEN_AUTH=changeme
CORS_ORIGIN=http://localhost:3000

# Global game-data admins: configure the account UUID and/or email.
GAME_DATA_ADMIN_ACCOUNT_ID=
GAME_DATA_ADMIN_EMAIL=
# Server-only shared secret; set the same value in the Next.js frontend.
GAME_DATA_ADMIN_PROXY_TOKEN=
# Protected maps require a global admin AND x-protected-map-override: true.
# Ullathorpe (1) is protected by default. Add other city/map IDs as needed.
GAME_DATA_PROTECTED_MAP_IDS=1
# Grant a collaborator map 50 only (run as a database operator):
# INSERT INTO game_map_editors (account_id, map_num) VALUES ('<account-uuid>', 50);
# Revoke with DELETE FROM game_map_editors WHERE account_id = '<account-uuid>' AND map_num = 50;

# Optional filesystem map root; defaults to src/mapas_source.
# GAME_DATA_MAPS_SOURCE_DIR=/srv/openao/maps
70 changes: 70 additions & 0 deletions api/MAP_EDITING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Permisos de edicion de mapas

Aplicar `schema.sql` con el mecanismo de migracion habitual antes de desplegar.
La tabla `game_map_editors` asigna cuentas a mapas individuales. Ejemplo, usando
el UUID real de una cuenta:

```sql
INSERT INTO game_map_editors (account_id, map_num)
VALUES ('00000000-0000-0000-0000-000000000001', 50)
ON CONFLICT DO NOTHING;

DELETE FROM game_map_editors
WHERE account_id = '00000000-0000-0000-0000-000000000001' AND map_num = 50;
```

No hay endpoint publico para otorgar permisos. La cuenta asignada puede consultar
los catalogos de objetos/NPCs, pero no modificar sus definiciones ni subir
graficos globales. Esas acciones siguen reservadas al administrador.

`GAME_DATA_ADMIN_ACCOUNT_ID` o `GAME_DATA_ADMIN_EMAIL` identifica al administrador.
`GAME_DATA_ADMIN_PROXY_TOKEN` autentica el proxy de Next; no sustituye la sesion
de la cuenta. El navegador solo envia su cookie y la API deriva el actor de esa
sesion, sin aceptar un account ID del cuerpo o de encabezados del cliente.

`GAME_DATA_PROTECTED_MAP_IDS` contiene enteros positivos separados por comas;
por defecto protege el mapa 1. La escritura requiere **ambos**: una sesion de
administrador y `x-protected-map-override: true`. Un permiso individual no
habilita este override. El editor muestra un consentimiento por mapa que se
reinicia al cambiar de mapa; el proxy solo transmite el valor literal `true`
para PUT, POST y DELETE de mapas.

## Atribucion y fallos

`game_map_edit_audit` conserva mapa, cuenta, accion, detalles y fecha. Los cambios
SQL y su auditoria se confirman en la misma transaccion, incluyendo los borrados.
El UUID del actor permanece en el registro aunque se elimine la cuenta.

Los NPCs de `npcs.json` utilizan otra ruta de almacenamiento. La API registra
primero una intencion durable con snapshots `before` y `after`, reemplaza el
archivo mediante rename y despues marca `details.outcome = applied`. Si falla
el reemplazo, intenta marcar `failed`; si se corta el proceso o la conexion,
puede quedar `pending`. No hay transaccion atomica entre PostgreSQL y archivos.

Ante un registro pendiente, detener las escrituras de ese mapa y comparar el
archivo con los snapshots, siguiendo el orden de los registros. No reintentar
automaticamente ni interpretar `pending` como un cambio confirmado. Si el
archivo coincide con `after`, verificar tambien si hubo escrituras posteriores;
si coincide con `before`, el cambio puede no haberse aplicado. Un resultado
distinto necesita revision del operador. Conservar siempre el registro original.

Las operaciones de NPC usan el bloqueo por mapa ya existente del proceso.
No desplegar varios escritores sobre el mismo directorio sin un mecanismo de
bloqueo compartido. `GAME_DATA_MAPS_SOURCE_DIR` permite configurar ese directorio;
por defecto se mantiene `src/mapas_source`.

## Verificacion

Con PostgreSQL disponible y `DATABASE_URL` / `TOKEN_AUTH` configurados:

```sh
pnpm exec tsc --noEmit
pnpm exec vitest run src/tests/map-permissions.integration.test.ts
```

La suite crea un esquema PostgreSQL y un directorio efimeros, inicia su propia
API y elimina sus fixtures al terminar. Requiere permiso para crear esquemas.
Ejercita HTTP real y los handlers reales del proxy; solo simula las cookies y
las dependencias de Next, no el fetch hacia la API. No necesita el servidor
Next para esas pruebas. Los tests visuales y el build del frontend son checks
separados.
20 changes: 20 additions & 0 deletions api/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -652,3 +652,23 @@ 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);


-- Operators grant/revoke map-specific access through this table.
CREATE TABLE IF NOT EXISTS game_map_editors (
map_num INTEGER NOT NULL CHECK (map_num > 0),
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
PRIMARY KEY (account_id, map_num)
);

-- Keep the actor UUID even if the account is later removed.
CREATE TABLE IF NOT EXISTS game_map_edit_audit (
id BIGSERIAL PRIMARY KEY,
map_num INTEGER NOT NULL CHECK (map_num > 0),
account_id UUID NOT NULL,
action TEXT NOT NULL,
details JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS idx_game_map_edit_audit_map_time
ON game_map_edit_audit (map_num, created_at);
11 changes: 11 additions & 0 deletions api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type Config = {
sesSecretAccessKey: string | null;
sesFromEmail: string | null;
sesFromName: string;
protectedMapIds: number[];
gameDataAdminEmail: string;
gameDataAdminAccountId: string | null;
gameDataAdminProxyToken: string | null;
Expand Down Expand Up @@ -75,6 +76,15 @@ function getOptionalNumberEnv(name: string, fallback: number): number {
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}

function getProtectedMapIds(): number[] {
const raw = process.env.GAME_DATA_PROTECTED_MAP_IDS?.trim() || "1";
const ids = raw.split(",").map((value) => Number(value.trim()));
if (ids.some((id) => !Number.isSafeInteger(id) || id <= 0)) {
throw new Error("GAME_DATA_PROTECTED_MAP_IDS must contain positive map IDs");
}
return [...new Set(ids)];
}

readEnvFile();

const config: Config = {
Expand All @@ -94,6 +104,7 @@ const config: Config = {
sesSecretAccessKey: process.env.SES_SECRET_ACCESS_KEY?.trim() || null,
sesFromEmail: process.env.SES_FROM_EMAIL?.trim() || null,
sesFromName: process.env.SES_FROM_NAME?.trim() || "AOWeb",
protectedMapIds: getProtectedMapIds(),
gameDataAdminEmail: (process.env.GAME_DATA_ADMIN_EMAIL?.trim() || "").toLowerCase(),
gameDataAdminAccountId: process.env.GAME_DATA_ADMIN_ACCOUNT_ID?.trim() || null,
gameDataAdminProxyToken: process.env.GAME_DATA_ADMIN_PROXY_TOKEN?.trim() || null,
Expand Down
19 changes: 15 additions & 4 deletions api/src/lib/mapNpcStorage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from "crypto";
import { existsSync } from "fs";
import fs from "fs/promises";
import path from "path";
Expand Down Expand Up @@ -175,13 +176,21 @@ export async function saveMapNpcPlacements(
...(p.movement !== undefined ? { movement: p.movement } : {}),
}));

await fs.writeFile(filePath, JSON.stringify(formatted, null, 2), "utf8");
const temporary = `${filePath}.${randomUUID()}.tmp`;
try {
await fs.writeFile(temporary, JSON.stringify(formatted, null, 2), { encoding: "utf8", flag: "wx" });
await fs.rename(temporary, filePath);
} finally {
// La limpieza no puede convertir un reemplazo exitoso en un fallo.
await fs.unlink(temporary).catch(() => undefined);
}
}

export async function placeMapNpc(
mapsSourceDir: string,
rawPlacement: unknown,
options: {
persist?: typeof saveMapNpcPlacements;
maxNpcs?: number;
isTileBlocked?: (x: number, y: number) => boolean;
isValidNpcIndex?: (npcIndex: number) => boolean | Promise<boolean>;
Expand Down Expand Up @@ -217,7 +226,7 @@ export async function placeMapNpc(
}

const updated = [...currentPlacements, placement];
await saveMapNpcPlacements(mapsSourceDir, placement.mapNum, updated);
await (options.persist ?? saveMapNpcPlacements)(mapsSourceDir, placement.mapNum, updated);

return { ok: true, placements: sortPlacements(updated) };
});
Expand All @@ -231,6 +240,7 @@ export async function moveMapNpc(
toX: number,
toY: number,
options: {
persist?: typeof saveMapNpcPlacements;
isTileBlocked?: (x: number, y: number) => boolean;
} = {},
): Promise<{ ok: true; placements: MapNpcPlacement[] } | { ok: false; reason: string }> {
Expand Down Expand Up @@ -259,7 +269,7 @@ export async function moveMapNpc(
const updated = currentPlacements.filter((_, idx) => idx !== sourceIndex);
updated.push({ ...targetNpc, x: toX, y: toY });

await saveMapNpcPlacements(mapsSourceDir, mapNum, updated);
await (options.persist ?? saveMapNpcPlacements)(mapsSourceDir, mapNum, updated);
return { ok: true, placements: sortPlacements(updated) };
});
}
Expand All @@ -269,6 +279,7 @@ export async function removeMapNpc(
mapNum: number,
x: number,
y: number,
options: { persist?: typeof saveMapNpcPlacements } = {},
): Promise<{ ok: true; placements: MapNpcPlacement[] } | { ok: false; reason: string }> {
return withMapLock(mapNum, async () => {
const currentPlacements = await loadMapNpcPlacements(mapsSourceDir, mapNum);
Expand All @@ -278,7 +289,7 @@ export async function removeMapNpc(
return { ok: false, reason: `No se encontró ningún NPC en (${x}, ${y}) para remover del mapa ${mapNum}.` };
}

await saveMapNpcPlacements(mapsSourceDir, mapNum, filtered);
await (options.persist ?? saveMapNpcPlacements)(mapsSourceDir, mapNum, filtered);
return { ok: true, placements: sortPlacements(filtered) };
});
}
39 changes: 39 additions & 0 deletions api/src/repositories/mapAudit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { PoolClient } from "pg";
import pool from "../db";

export async function recordMapEdit(
client: PoolClient,
mapNum: number,
accountId: string,
action: string,
details: Record<string, unknown>,
): Promise<void> {
await client.query(
`INSERT INTO game_map_edit_audit (map_num, account_id, action, details)
VALUES ($1, $2, $3, $4::jsonb)`,
[mapNum, accountId, action, JSON.stringify(details)],
);
}

/** Mutation and attribution commit together, including destructive operations. */
export async function auditedMapEdit<T>(
mapNum: number,
accountId: string,
action: string,
details: Record<string, unknown>,
mutate: (client: PoolClient) => Promise<T>,
): Promise<T> {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await mutate(client);
await recordMapEdit(client, mapNum, accountId, action, details);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
34 changes: 34 additions & 0 deletions api/src/repositories/mapNpcAudit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import pool from "../db";
import { loadMapNpcPlacements, saveMapNpcPlacements } from "../lib/mapNpcStorage";

/**
* El archivo y PostgreSQL no comparten transaccion. Guardamos primero la
* intencion durable (actor y snapshots), luego reemplazamos el archivo y
* marcamos el resultado. Un pending tras un corte requiere reconciliacion;
* nunca implica por si solo que el cambio se aplico.
* Se invoca dentro del bloqueo del mapa de mapNpcStorage.
*/
export function auditedNpcSave(accountId: string, action: string): typeof saveMapNpcPlacements {
return async (directory, mapNum, placements) => {
const before = await loadMapNpcPlacements(directory, mapNum);
const result = await pool.query<{ id: string }>(
`INSERT INTO game_map_edit_audit (map_num, account_id, action, details)
VALUES ($1, $2, $3, $4::jsonb) RETURNING id`,
[mapNum, accountId, action, JSON.stringify({ outcome: "pending", before, after: placements })],
);
const id = result.rows[0].id;
try {
await saveMapNpcPlacements(directory, mapNum, placements);
} catch (error) {
await pool.query(
`UPDATE game_map_edit_audit SET details = details || '{"outcome":"failed"}'::jsonb WHERE id = $1`,
[id],
).catch(() => undefined);
throw error;
}
await pool.query(
`UPDATE game_map_edit_audit SET details = details || '{"outcome":"applied"}'::jsonb WHERE id = $1`,
[id],
);
};
}
18 changes: 18 additions & 0 deletions api/src/repositories/mapPermissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import pool from "../db";

/** Grants are scoped to one map; no wildcard or client-supplied role exists. */
export async function listEditableMaps(accountId: string): Promise<number[]> {
const result = await pool.query<{ map_num: number }>(
"SELECT map_num FROM game_map_editors WHERE account_id = $1 ORDER BY map_num",
[accountId],
);
return result.rows.map((row) => row.map_num);
}

export async function canEditMap(accountId: string, mapNum: number): Promise<boolean> {
const result = await pool.query(
"SELECT 1 FROM game_map_editors WHERE account_id = $1 AND map_num = $2",
[accountId, mapNum],
);
return result.rowCount === 1;
}
Loading