lib/web-api.ts
32 KB · sha256:e1172ceafe2e8449e6dc266b9d9f14db0d1126bbfdfd240a3a35fa21618cd150
import {
addGroup,
addPermission,
denyPermission,
findPlayerByUuid,
getPlayerGroups,
listPlayers,
removeGroup,
removePermission,
resolvePlayerPermissions,
resolvePrefix,
resolveSuffix,
searchPlayers,
setGroup,
setPrefix,
setSuffix,
upsertPlayer,
} from "../models/Player";
import {
addParent,
addPermission as addGroupPermission,
cloneGroup,
createGroup,
deleteGroup,
findGroup,
getDefaultGroup,
getParents,
listGroups,
removeParent,
removePermission as removeGroupPermission,
setDefaultGroup,
setPrefix as setGroupPrefix,
setSuffix as setGroupSuffix,
setWeight,
} from "../models/Group";
import {
addRungCost,
addRungRequirement,
appendToTrack,
createTrack,
deleteTrack,
findTrack,
insertIntoTrack,
listTracks,
removeFromTrack,
removeRungCost,
removeRungRequirement,
setRungAutoPromote,
setTrackPollSeconds,
type TransactionConfig,
} from "../models/Track";
import {
addToBundle,
createBundle,
deleteBundle,
findBundle,
listBundles,
removeFromBundle,
setBundleGroups,
} from "../models/GroupBundle";
import { refreshOne, refreshGroupMembers } from "./attachments";
import { attemptDemote, attemptPromote } from "./track-engine";
import { onlineByUuid } from "./identity";
import { compileExpr, ExprParseError } from "./expr";
import { expandMathTemplate } from "./template-expr";
import { getHandler, listHandlers, missingPluginFor } from "./transactions";
import { invalidateGroup } from "../placeholders/papi";
import {
extractAuthHeader,
isAuthResponse,
lookupBearer,
requirePerm,
unauthorized,
type AuthSession,
} from "./web-auth";
const JSON_HEADERS = { "Content-Type": "application/json; charset=utf-8" };
function ok<T>(data: T) {
return { status: 200, headers: JSON_HEADERS, body: JSON.stringify(data) };
}
function badRequest(msg: string) {
return {
status: 400,
headers: JSON_HEADERS,
body: JSON.stringify({ error: msg }),
};
}
function notFound(msg: string) {
return {
status: 404,
headers: JSON_HEADERS,
body: JSON.stringify({ error: msg }),
};
}
/**
* Convert a 0-indexed integer to its alphabetical sequence string:
* 0 -> a, 25 -> z, 26 -> aa, 27 -> ab, ..., 701 -> zz, 702 -> aaa. Used
* by bulk-create's `{a}` / `{A}` template tokens so admins can spin up
* `mine-a` ... `mine-z` ... `mine-aa` automatically without doing the
* base-26 math themselves.
*/
function toAlpha(n: number): string {
if (n < 0) return "";
let out = "";
let i = n;
while (i >= 0) {
out = String.fromCharCode(97 + (i % 26)) + out;
i = Math.floor(i / 26) - 1;
}
return out;
}
const PERM = {
web: "ward.web",
userInfo: "ward.user.info",
userEdit: "ward.user.edit",
groupInfo: "ward.group.info",
groupEdit: "ward.group.edit",
trackInfo: "ward.track.info",
trackEdit: "ward.track.edit",
} as const;
type Handler = (
c: ServeContext,
auth: AuthSession,
) => Promise<ReturnType<typeof ok>> | ReturnType<typeof ok>;
function gate(perm: string, handler: Handler) {
return async (c: ServeContext) => {
const auth = await requirePerm(c, perm);
if (isAuthResponse(auth)) return auth;
return handler(c, auth);
};
}
export function registerWebApi(app: ServeApp): void {
app.get("/api/health", () => ok({ ok: true }));
// ---- Auth surface ----------------------------------------------------
// /api/auth/whoami: the dashboard hits this on load to find out who
// the bearer token belongs to + what they can do. Used to render the
// header chip + hide controls the player lacks perms for.
app.get("/api/auth/whoami", async (c) => {
const session = lookupBearer(extractAuthHeader(c.req.headers));
if (!session) return unauthorized();
const baseGate = await requirePerm(c, PERM.web);
if (isAuthResponse(baseGate)) return baseGate;
const [perms] = await Promise.all([resolvePlayerPermissions(session.uuid)]);
const has = (p: string) => perms.get(p) === true;
return ok({
uuid: session.uuid,
name: session.name,
perms: {
userInfo: has(PERM.userInfo),
userEdit: has(PERM.userEdit),
groupInfo: has(PERM.groupInfo),
groupEdit: has(PERM.groupEdit),
trackInfo: has(PERM.trackInfo),
trackEdit: has(PERM.trackEdit),
},
});
});
// ---- Dashboard overview ---------------------------------------------
app.get(
"/api/overview",
gate(PERM.web, async () => {
const [players, groups, tracks] = await Promise.all([
listPlayers(1000, 0),
listGroups(),
listTracks(),
]);
const online = rune.bukkit.getOnlinePlayers().length;
return ok({
players: players.length,
groups: groups.length,
tracks: tracks.length,
online,
maxPlayers: rune.bukkit.getMaxPlayers(),
handlers: listHandlers(),
});
}),
);
app.get("/api/handlers", gate(PERM.web, () => ok(listHandlers())));
// ---- Players ---------------------------------------------------------
app.get(
"/api/players",
gate(PERM.userInfo, async (c) => {
const q = c.query("q");
const docs = q ? await searchPlayers(q, 200) : await listPlayers(500, 0);
const onlineUuids = new Set(
rune.bukkit
.getOnlinePlayers()
.map((p) => String(p.getUniqueId()).toLowerCase()),
);
return ok(
docs.map((d) => ({
uuid: d.uuid,
username: d.username || d.uuid,
primaryGroup: d.primaryGroup,
nodeCount: d.nodes.length,
online: onlineUuids.has(d.uuid.toLowerCase()),
})),
);
}),
);
app.get(
"/api/players/:uuid",
gate(PERM.userInfo, async (c) => {
const uuid = c.param("uuid")!;
const doc = await findPlayerByUuid(uuid);
if (!doc) return notFound(`player not found: ${uuid}`);
const [groups, prefix, suffix] = await Promise.all([
getPlayerGroups(uuid),
resolvePrefix(uuid),
resolveSuffix(uuid),
]);
return ok({
uuid: doc.uuid,
username: doc.username || doc.uuid,
primaryGroup: doc.primaryGroup,
groups,
prefix,
suffix,
nodes: doc.nodes,
online: !!onlineByUuid(uuid),
});
}),
);
app.get(
"/api/players/:uuid/permissions",
gate(PERM.userInfo, async (c) => {
const uuid = c.param("uuid")!;
const perms = await resolvePlayerPermissions(uuid);
return ok([...perms.entries()].map(([key, value]) => ({ key, value })));
}),
);
app.post(
"/api/players/:uuid/primary-group",
gate(PERM.userEdit, async (c) => {
const uuid = c.param("uuid")!;
const { group } = (await c.req.json()) as { group: string };
if (!group) return badRequest("group required");
await setGroup(uuid, group);
await refreshOne(uuid);
return ok({ ok: true });
}),
);
app.post(
"/api/players/:uuid/groups",
gate(PERM.userEdit, async (c) => {
const uuid = c.param("uuid")!;
const { group } = (await c.req.json()) as { group: string };
if (!group) return badRequest("group required");
if (!(await findGroup(group))) return notFound(`group not found: ${group}`);
await addGroup(uuid, group);
await refreshOne(uuid);
return ok({ ok: true });
}),
);
app.delete(
"/api/players/:uuid/groups/:group",
gate(PERM.userEdit, async (c) => {
const uuid = c.param("uuid")!;
const group = c.param("group")!;
await removeGroup(uuid, group);
await refreshOne(uuid);
return ok({ ok: true });
}),
);
app.post(
"/api/players/:uuid/prefix",
gate(PERM.userEdit, async (c) => {
const uuid = c.param("uuid")!;
const { text, priority } = (await c.req.json()) as {
text: string;
priority?: number;
};
await upsertPlayer(uuid, "");
await setPrefix(uuid, text ?? "", priority ?? 100);
await refreshOne(uuid);
return ok({ ok: true });
}),
);
app.post(
"/api/players/:uuid/suffix",
gate(PERM.userEdit, async (c) => {
const uuid = c.param("uuid")!;
const { text, priority } = (await c.req.json()) as {
text: string;
priority?: number;
};
await upsertPlayer(uuid, "");
await setSuffix(uuid, text ?? "", priority ?? 100);
await refreshOne(uuid);
return ok({ ok: true });
}),
);
app.post(
"/api/players/:uuid/permissions",
gate(PERM.userEdit, async (c) => {
const uuid = c.param("uuid")!;
const { permission, value } = (await c.req.json()) as {
permission: string;
value?: boolean;
};
if (!permission) return badRequest("permission required");
if (value === false) await denyPermission(uuid, permission);
else await addPermission(uuid, permission);
await refreshOne(uuid);
return ok({ ok: true });
}),
);
app.delete(
"/api/players/:uuid/permissions/:perm",
gate(PERM.userEdit, async (c) => {
const uuid = c.param("uuid")!;
const perm = c.param("perm")!;
await removePermission(uuid, perm);
await refreshOne(uuid);
return ok({ ok: true });
}),
);
app.post(
"/api/players/:uuid/promote",
gate(PERM.userEdit, async (c) => {
const uuid = c.param("uuid")!;
const { track } = (await c.req.json()) as { track: string };
if (!track) return badRequest("track required");
const online = onlineByUuid(uuid);
if (!online) {
return ok({
ok: false,
reason: "player not online (cost-based promotion requires live player)",
});
}
const groups = await getPlayerGroups(uuid);
const res = await attemptPromote(online, track, groups);
return ok(res);
}),
);
app.post(
"/api/players/:uuid/demote",
gate(PERM.userEdit, async (c) => {
const uuid = c.param("uuid")!;
const { track } = (await c.req.json()) as { track: string };
if (!track) return badRequest("track required");
const online = onlineByUuid(uuid);
if (!online) return ok({ ok: false, reason: "player not online" });
const res = await attemptDemote(online, track);
return ok(res);
}),
);
app.post(
"/api/players/:uuid/clear",
gate(PERM.userEdit, async (c) => {
const uuid = c.param("uuid")!;
const doc = await findPlayerByUuid(uuid);
if (!doc) return notFound(`player not found: ${uuid}`);
doc.nodes.splice(0, doc.nodes.length);
await doc.save();
await refreshOne(uuid);
return ok({ ok: true });
}),
);
// ---- Groups ----------------------------------------------------------
app.get(
"/api/groups",
gate(PERM.groupInfo, async () => {
const groups = (await listGroups())
.slice()
.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0));
const def = await getDefaultGroup();
const defLc = def?.name?.toLowerCase();
return ok(
groups.map((g) => ({
name: g.name,
displayName: g.displayName,
weight: g.weight ?? 0,
nodeCount: g.nodes?.length ?? 0,
isDefault: g.name.toLowerCase() === defLc,
})),
);
}),
);
app.get(
"/api/groups/:name",
gate(PERM.groupInfo, async (c) => {
const name = c.param("name")!;
const g = await findGroup(name);
if (!g) return notFound(`group not found: ${name}`);
const parents = await getParents(name);
const def = await getDefaultGroup();
return ok({
name: g.name,
displayName: g.displayName,
weight: g.weight ?? 0,
parents,
nodes: g.nodes,
isDefault: def?.name?.toLowerCase() === g.name.toLowerCase(),
});
}),
);
app.post(
"/api/groups",
gate(PERM.groupEdit, async (c) => {
const { name, weight, displayName } = (await c.req.json()) as {
name: string;
weight?: number;
displayName?: string;
};
if (!name) return badRequest("name required");
try {
const g = await createGroup(name, { weight, displayName });
return ok({ name: g.name });
} catch (e) {
return badRequest((e as Error).message);
}
}),
);
// Clone an existing group into a new one. Copies every node from source
// (permissions, parents, prefix/suffix, meta), then optionally overrides
// weight + displayName.
app.post(
"/api/groups/:name/clone",
gate(PERM.groupEdit, async (c) => {
const source = c.param("name")!;
const { newName, weight, displayName } = (await c.req.json()) as {
newName: string;
weight?: number;
displayName?: string;
};
if (!newName) return badRequest("newName required");
try {
const g = await cloneGroup(source, newName, { weight, displayName });
return ok({ name: g.name });
} catch (e) {
return badRequest((e as Error).message);
}
}),
);
// Create many groups in one call. Either supply explicit `names` OR a
// pattern (`template` with substitutions over `start..end`). Supports
// alphabetical sequencing (`{a}`/`{A}` -> a..z, aa..zz, aaa..),
// weight-stepping, node-cloning from a template group, auto-parenting
// (chain previous OR fixed `inheritFrom`), and optional bundle
// assignment (existing or freshly-created in-line). Per-row failures
// are reported in `failed` instead of bombing the batch.
app.post(
"/api/groups/bulk",
gate(PERM.groupEdit, async (c) => {
const body = (await c.req.json()) as {
names?: string[];
template?: string;
start?: number;
end?: number;
baseWeight?: number;
weightIncrement?: number;
cloneFrom?: string;
chainParents?: boolean;
inheritFrom?: string;
bundle?: { name: string; create?: boolean };
};
let names: string[];
if (Array.isArray(body.names) && body.names.length > 0) {
names = body.names.map((n) => String(n).trim()).filter(Boolean);
} else if (body.template) {
const start = body.start ?? 1;
const end = body.end ?? start;
if (end < start) return badRequest("end must be >= start");
if (end - start > 200) return badRequest("max 200 groups per batch");
names = [];
for (let i = start; i <= end; i++) {
const alphaIdx = i - start;
names.push(
body.template
.replace(/\{i\}/g, String(i))
.replace(/\{i:02\}/g, String(i).padStart(2, "0"))
.replace(/\{i:03\}/g, String(i).padStart(3, "0"))
.replace(/\{a\}/g, toAlpha(alphaIdx))
.replace(/\{A\}/g, toAlpha(alphaIdx).toUpperCase()),
);
}
} else {
return badRequest("names[] or template required");
}
if (names.length === 0) return badRequest("no names to create");
const base = body.baseWeight ?? 0;
const inc = body.weightIncrement ?? 0;
const created: string[] = [];
const failed: { name: string; reason: string }[] = [];
for (let i = 0; i < names.length; i++) {
const name = names[i];
const weight = base + i * inc;
try {
if (body.cloneFrom) {
await cloneGroup(body.cloneFrom, name, { weight });
} else {
await createGroup(name, { weight });
}
created.push(name);
// Auto-parenting: fixed parent applies to every new group;
// chain mode wires each to the previously-created group in
// this batch (so {a}{b}{c}{d} forms a → b → c → d inheritance
// ladder, perfect for prison ranks). Both can be set; both run.
if (body.inheritFrom) {
try {
await addParent(name, body.inheritFrom);
} catch (pe) {
failed.push({
name,
reason: `created but inheritFrom failed: ${(pe as Error).message}`,
});
}
}
if (body.chainParents && i > 0) {
try {
await addParent(name, names[i - 1]);
} catch (pe) {
failed.push({
name,
reason: `created but chainParent failed: ${(pe as Error).message}`,
});
}
}
} catch (e) {
failed.push({ name, reason: (e as Error).message });
}
}
// Optional bundle assignment. Create the bundle if requested and
// missing, then replace its `groups` with the just-created set so
// ordering matches creation order.
if (body.bundle?.name) {
const bName = body.bundle.name;
try {
let existing = await findBundle(bName);
if (!existing) {
if (body.bundle.create) {
existing = await createBundle(bName, { groups: created });
} else {
failed.push({
name: bName,
reason: `bundle '${bName}' doesn't exist (set create: true to make it)`,
});
}
} else {
// Append, dedupe, keep order: existing first, then newly created.
const next = [...existing.groups];
for (const g of created) if (!next.includes(g)) next.push(g);
await setBundleGroups(bName, next);
}
} catch (e) {
failed.push({
name: bName,
reason: `bundle assignment failed: ${(e as Error).message}`,
});
}
}
return ok({ created, failed });
}),
);
app.delete(
"/api/groups/:name",
gate(PERM.groupEdit, async (c) => {
const name = c.param("name")!;
await deleteGroup(name);
invalidateGroup(name);
return ok({ ok: true });
}),
);
app.post(
"/api/groups/:name/weight",
gate(PERM.groupEdit, async (c) => {
const name = c.param("name")!;
const { weight } = (await c.req.json()) as { weight: number };
if (!Number.isFinite(weight)) return badRequest("weight (number) required");
await setWeight(name, weight);
invalidateGroup(name);
await refreshGroupMembers(name);
return ok({ ok: true });
}),
);
app.post(
"/api/groups/:name/prefix",
gate(PERM.groupEdit, async (c) => {
const name = c.param("name")!;
const { text, priority } = (await c.req.json()) as {
text: string;
priority?: number;
};
await setGroupPrefix(name, text ?? "", priority ?? 100);
await refreshGroupMembers(name);
return ok({ ok: true });
}),
);
app.post(
"/api/groups/:name/suffix",
gate(PERM.groupEdit, async (c) => {
const name = c.param("name")!;
const { text, priority } = (await c.req.json()) as {
text: string;
priority?: number;
};
await setGroupSuffix(name, text ?? "", priority ?? 100);
await refreshGroupMembers(name);
return ok({ ok: true });
}),
);
app.post(
"/api/groups/:name/parents",
gate(PERM.groupEdit, async (c) => {
const name = c.param("name")!;
const { parent } = (await c.req.json()) as { parent: string };
if (!parent) return badRequest("parent required");
await addParent(name, parent);
await refreshGroupMembers(name);
return ok({ ok: true });
}),
);
app.delete(
"/api/groups/:name/parents/:parent",
gate(PERM.groupEdit, async (c) => {
const name = c.param("name")!;
const parent = c.param("parent")!;
await removeParent(name, parent);
await refreshGroupMembers(name);
return ok({ ok: true });
}),
);
app.post(
"/api/groups/:name/permissions",
gate(PERM.groupEdit, async (c) => {
const name = c.param("name")!;
const { permission } = (await c.req.json()) as { permission: string };
if (!permission) return badRequest("permission required");
await addGroupPermission(name, permission);
await refreshGroupMembers(name);
return ok({ ok: true });
}),
);
app.delete(
"/api/groups/:name/permissions/:perm",
gate(PERM.groupEdit, async (c) => {
const name = c.param("name")!;
const perm = c.param("perm")!;
await removeGroupPermission(name, perm);
await refreshGroupMembers(name);
return ok({ ok: true });
}),
);
app.post(
"/api/groups/:name/default",
gate(PERM.groupEdit, async (c) => {
const name = c.param("name")!;
await setDefaultGroup(name);
return ok({ ok: true });
}),
);
// ---- Group bundles ---------------------------------------------------
// Bundles are named ordered lists of groups. Useful for templating
// tracks ("apply prison-ranks-a-z") and bulk role provisioning.
app.get(
"/api/bundles",
gate(PERM.groupInfo, async () => {
const bundles = await listBundles();
return ok(
bundles.map((b) => ({
name: b.name,
displayName: b.displayName,
groups: b.groups,
size: b.groups.length,
})),
);
}),
);
app.get(
"/api/bundles/:name",
gate(PERM.groupInfo, async (c) => {
const b = await findBundle(c.param("name")!);
if (!b) return notFound("bundle not found");
return ok({
name: b.name,
displayName: b.displayName,
groups: b.groups,
});
}),
);
app.post(
"/api/bundles",
gate(PERM.groupEdit, async (c) => {
const { name, displayName, groups } = (await c.req.json()) as {
name: string;
displayName?: string;
groups?: string[];
};
if (!name) return badRequest("name required");
try {
const b = await createBundle(name, { displayName, groups });
return ok({ name: b.name });
} catch (e) {
return badRequest((e as Error).message);
}
}),
);
app.delete(
"/api/bundles/:name",
gate(PERM.groupEdit, async (c) => {
await deleteBundle(c.param("name")!);
return ok({ ok: true });
}),
);
// Replace the whole ordered list -- used for reorder (drag/up-down).
app.put(
"/api/bundles/:name/groups",
gate(PERM.groupEdit, async (c) => {
const { groups } = (await c.req.json()) as { groups: string[] };
if (!Array.isArray(groups)) return badRequest("groups[] required");
const updated = await setBundleGroups(c.param("name")!, groups);
if (!updated) return notFound("bundle not found");
return ok({ ok: true });
}),
);
app.post(
"/api/bundles/:name/groups",
gate(PERM.groupEdit, async (c) => {
const { group } = (await c.req.json()) as { group: string };
if (!group) return badRequest("group required");
const updated = await addToBundle(c.param("name")!, group);
if (!updated) return notFound("bundle not found");
return ok({ ok: true });
}),
);
app.delete(
"/api/bundles/:name/groups/:group",
gate(PERM.groupEdit, async (c) => {
const updated = await removeFromBundle(
c.param("name")!,
c.param("group")!,
);
if (!updated) return notFound("bundle not found");
return ok({ ok: true });
}),
);
// ---- Tracks ----------------------------------------------------------
app.get(
"/api/tracks",
gate(PERM.trackInfo, async () => {
const tracks = (await listTracks())
.slice()
.sort((a, b) => a.name.localeCompare(b.name));
return ok(
tracks.map((t) => ({
name: t.name,
rungCount: t.rungs.length,
autoRungs: t.rungs.filter((r) => r.autoPromote).length,
pollSeconds: t.pollSeconds ?? 30,
rungs: t.rungs.map((r) => ({
group: r.group,
requirements: r.requirements.length,
costs: r.costs.length,
autoPromote: r.autoPromote,
})),
})),
);
}),
);
app.get(
"/api/tracks/:name",
gate(PERM.trackInfo, async (c) => {
const name = c.param("name")!;
const t = await findTrack(name);
if (!t) return notFound(`track not found: ${name}`);
return ok({
name: t.name,
pollSeconds: t.pollSeconds ?? 30,
rungs: t.rungs,
});
}),
);
app.post(
"/api/tracks",
gate(PERM.trackEdit, async (c) => {
const { name } = (await c.req.json()) as { name: string };
if (!name) return badRequest("name required");
try {
const t = await createTrack(name);
return ok({ name: t.name });
} catch (e) {
return badRequest((e as Error).message);
}
}),
);
app.delete(
"/api/tracks/:name",
gate(PERM.trackEdit, async (c) => {
const name = c.param("name")!;
await deleteTrack(name);
return ok({ ok: true });
}),
);
app.post(
"/api/tracks/:name/rungs",
gate(PERM.trackEdit, async (c) => {
const name = c.param("name")!;
const { group, position } = (await c.req.json()) as {
group: string;
position?: number;
};
if (!group) return badRequest("group required");
if (position == null) await appendToTrack(name, group);
else await insertIntoTrack(name, group, position);
return ok({ ok: true });
}),
);
// Bulk append (or insert) a list of rungs with per-row template
// expansion for requirements + cost amounts.
//
// Templates use the {} DSL: bare identifiers like `{vault_eco_balance}`
// are PAPI placeholders and are LEFT INTACT (Ward's expression
// evaluator resolves them at promote-time). Anything containing math
// (operators, numbers, functions) is evaluated with `i` (0-indexed),
// `n` (batch size), `idx` (1-indexed), and any user-supplied `vars`
// in scope.
//
// {
// groups: ["a","b","c"],
// requirements: ["{vault_eco_balance} >= {base * (1.1 ^ i)}"],
// costs: [{ handler: "vault:money", amount: "{base * (1.1 ^ i)}" }],
// vars: { base: 500 },
// autoPromote: true,
// position: null, // null = append; number = insert at index
// }
app.post(
"/api/tracks/:name/rungs/bulk",
gate(PERM.trackEdit, async (c) => {
const name = c.param("name")!;
const body = (await c.req.json()) as {
groups: string[];
requirements?: string[];
costs?: { handler: string; amount: number | string }[];
vars?: Record<string, number>;
autoPromote?: boolean;
position?: number | null;
};
const groups = (body.groups ?? [])
.map((g) => String(g).trim().toLowerCase())
.filter(Boolean);
if (groups.length === 0) return badRequest("groups[] required");
if (groups.length > 500) return badRequest("max 500 rungs per batch");
const reqTemplates = body.requirements ?? [];
const costTemplates = body.costs ?? [];
const userVars = body.vars ?? {};
// Validate cost handlers up front so we fail the whole batch
// cleanly rather than half-inserting.
for (const c of costTemplates) {
if (!getHandler(c.handler)) {
const missing = missingPluginFor(c.handler);
return badRequest(
missing
? `handler ${c.handler} requires plugin ${missing}`
: `unknown handler ${c.handler}`,
);
}
}
const t = await findTrack(name);
if (!t) return notFound(`track not found: ${name}`);
const n = groups.length;
const newRungs = groups.map((g, i) => {
const vars: Record<string, number> = {
...userVars,
i,
n,
idx: i + 1,
};
const requirements: string[] = [];
try {
for (const tpl of reqTemplates) {
requirements.push(expandMathTemplate(tpl, { vars }));
}
} catch (e) {
throw new Error(
`requirement template failed at rung ${g}: ${(e as Error).message}`,
);
}
// Validate each expanded requirement parses as a Ward expr so
// misformed templates can't poison the track silently.
for (const r of requirements) {
try {
compileExpr(r);
} catch (e) {
const msg = e instanceof ExprParseError ? e.message : (e as Error).message;
throw new Error(`requirement at rung ${g} doesn't parse: ${r} (${msg})`);
}
}
const costs: { handler: string; amount: number }[] = [];
for (const c of costTemplates) {
let amount: number;
if (typeof c.amount === "number") {
amount = c.amount;
} else {
const expanded = expandMathTemplate(c.amount, { vars });
const parsed = Number(expanded);
if (!Number.isFinite(parsed)) {
throw new Error(
`cost amount didn't evaluate to a number at rung ${g}: ` +
`"${c.amount}" -> "${expanded}"`,
);
}
amount = parsed;
}
costs.push({ handler: c.handler.toLowerCase(), amount });
}
return {
group: g,
requirements,
costs,
autoPromote: !!body.autoPromote,
};
});
let pos: number;
if (body.position == null) {
pos = t.rungs.length;
} else {
pos = Math.max(0, Math.min(body.position, t.rungs.length));
}
t.rungs.splice(pos, 0, ...newRungs);
t.markModified("rungs");
await t.save();
return ok({ created: newRungs.length, position: pos });
}),
);
app.delete(
"/api/tracks/:name/rungs/:group",
gate(PERM.trackEdit, async (c) => {
const name = c.param("name")!;
const group = c.param("group")!;
await removeFromTrack(name, group);
return ok({ ok: true });
}),
);
app.post(
"/api/tracks/:name/rungs/:index/requirements",
gate(PERM.trackEdit, async (c) => {
const name = c.param("name")!;
const index = parseInt(c.param("index")!, 10);
const { expression } = (await c.req.json()) as { expression: string };
if (!expression) return badRequest("expression required");
try {
compileExpr(expression);
} catch (e) {
return badRequest(
e instanceof ExprParseError ? e.message : (e as Error).message,
);
}
await addRungRequirement(name, index, expression);
return ok({ ok: true });
}),
);
app.delete(
"/api/tracks/:name/rungs/:index/requirements/:reqIndex",
gate(PERM.trackEdit, async (c) => {
const name = c.param("name")!;
const index = parseInt(c.param("index")!, 10);
const reqIndex = parseInt(c.param("reqIndex")!, 10);
await removeRungRequirement(name, index, reqIndex);
return ok({ ok: true });
}),
);
app.post(
"/api/tracks/:name/rungs/:index/costs",
gate(PERM.trackEdit, async (c) => {
const name = c.param("name")!;
const index = parseInt(c.param("index")!, 10);
const { handler, amount } = (await c.req.json()) as {
handler: string;
amount: number;
};
if (!handler) return badRequest("handler required");
if (!getHandler(handler)) {
const missing = missingPluginFor(handler);
return badRequest(
missing
? `handler ${handler} requires plugin ${missing}`
: `unknown handler ${handler}`,
);
}
const cfg: TransactionConfig = { handler: handler.toLowerCase(), amount };
await addRungCost(name, index, cfg);
return ok({ ok: true });
}),
);
app.delete(
"/api/tracks/:name/rungs/:index/costs/:costIndex",
gate(PERM.trackEdit, async (c) => {
const name = c.param("name")!;
const index = parseInt(c.param("index")!, 10);
const costIndex = parseInt(c.param("costIndex")!, 10);
await removeRungCost(name, index, costIndex);
return ok({ ok: true });
}),
);
app.post(
"/api/tracks/:name/rungs/:index/autopromote",
gate(PERM.trackEdit, async (c) => {
const name = c.param("name")!;
const index = parseInt(c.param("index")!, 10);
const { on } = (await c.req.json()) as { on: boolean };
await setRungAutoPromote(name, index, !!on);
return ok({ ok: true });
}),
);
app.post(
"/api/tracks/:name/poll-seconds",
gate(PERM.trackEdit, async (c) => {
const name = c.param("name")!;
const { seconds } = (await c.req.json()) as { seconds: number };
await setTrackPollSeconds(name, seconds);
return ok({ ok: true });
}),
);
}