1
0
Fork 0
polaris-task-force/src/app/(frontend)/logistics/banking/actions.ts
Z8MB1E c0d00fc113 fix(auth): reject MCP API-key sessions in user auth paths
With the MCP plugin enabled, payload.auth() can return a
payload-mcp-api-keys doc instead of a User. Add an isPayloadUser
type guard and use it in every server action, page, and route that
treats the auth result as a real user, so API-key sessions can no
longer pass user auth checks. hasRoles now explicitly requires a
users-collection doc.
2026-08-25 12:27:35 -04:00

362 lines
12 KiB
TypeScript

"use server";
import config from "@payload-config";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { getPayload } from "payload";
import type { User } from "@/payload-types";
import { hasPermission } from "@/utils/access-control/hasPermission";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import {
applyTransaction,
createAccount,
ensurePersonalAccount,
getMainCurrencyName,
} from "@/lib/banking";
import { notifyUser } from "@/lib/notifications";
interface ActionResult<T = undefined> {
success: boolean;
error?: string;
data?: T;
}
async function authenticate() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({
headers: hdrs,
canSetHeaders: false,
});
if (!isPayloadUser(user)) {
throw new Error("Unauthorized");
}
return { payload, user };
}
async function isBankingManager(
payload: Awaited<ReturnType<typeof getPayload>>,
user: User,
): Promise<boolean> {
if (await hasPermission(payload, user, "banking:manage")) return true;
return hasLogisticsQualification(payload, user);
}
export async function createBankAccount(input: {
name: string;
accountType: "treasury" | "faction" | "personal";
ownerFactionId?: number;
ownerUserId?: number;
}): Promise<ActionResult<number>> {
try {
const { payload, user } = await authenticate();
const manager = await isBankingManager(payload, user);
if (input.accountType === "personal") {
const ownerId = input.ownerUserId ?? (user.id as number);
if (!manager && ownerId !== user.id) {
return {
success: false,
error: "You can only create a personal account for yourself.",
};
}
const existing = await payload.find({
collection: "bank-accounts",
where: {
and: [{ accountType: { equals: "personal" } }, { ownerUser: { equals: ownerId } }],
},
limit: 1,
depth: 0,
overrideAccess: true,
});
if (existing.docs.length > 0) {
return {
success: false,
error: "This user already has a personal account.",
};
}
const account = await createAccount(payload, {
name: input.name,
accountType: "personal",
ownerUserId: ownerId,
});
await emitGameEvent(payload, {
type: EventTypes.BankAccountCreate,
message: `Created personal account "${account.name}"`,
actor: user.id,
targetCollection: "bank-accounts",
targetId: account.id,
data: { accountType: "personal", ownerUserId: ownerId },
});
return { success: true, data: account.id };
}
if (!manager) {
return {
success: false,
error: "Insufficient permissions. Logistics personnel or higher can manage unit accounts.",
};
}
if (input.accountType === "faction" && !input.ownerFactionId) {
return {
success: false,
error: "A faction account requires an owner faction.",
};
}
const account = await createAccount(payload, {
name: input.name,
accountType: input.accountType,
ownerFactionId: input.ownerFactionId,
});
await emitGameEvent(payload, {
type: EventTypes.BankAccountCreate,
message: `Created ${input.accountType} account "${account.name}"`,
actor: user.id,
targetCollection: "bank-accounts",
targetId: account.id,
data: { accountType: input.accountType, ownerFactionId: input.ownerFactionId },
});
return { success: true, data: account.id };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
export async function ensureMyAccount(): Promise<ActionResult<number>> {
try {
const { payload, user } = await authenticate();
const account = await ensurePersonalAccount(payload, user.id as number);
await emitGameEvent(payload, {
type: EventTypes.BankAccountCreate,
message: `Created personal account "${account.name}"`,
actor: user.id,
targetCollection: "bank-accounts",
targetId: account.id,
data: { accountType: "personal", ownerUserId: user.id },
});
return { success: true, data: account.id };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
export async function depositFunds(
accountId: number,
amount: number,
memo?: string,
): Promise<ActionResult<number>> {
return moveFunds("deposit", undefined, accountId, amount, memo);
}
export async function withdrawFunds(
accountId: number,
amount: number,
memo?: string,
): Promise<ActionResult<number>> {
return moveFunds("withdrawal", accountId, undefined, amount, memo);
}
export async function transferFunds(
fromAccountId: number,
toAccountId: number,
amount: number,
memo?: string,
): Promise<ActionResult<number>> {
return moveFunds("transfer", fromAccountId, toAccountId, amount, memo);
}
async function moveFunds(
type: "deposit" | "withdrawal" | "transfer",
fromAccountId: number | undefined,
toAccountId: number | undefined,
amount: number,
memo?: string,
): Promise<ActionResult<number>> {
try {
const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "bank-accounts:create"))) {
return { success: false, error: "Insufficient permissions." };
}
if (!amount || amount <= 0) {
return { success: false, error: "Amount must be greater than zero." };
}
const manager = await isBankingManager(payload, user);
const accountChecks: { id?: number; label: string }[] = [];
if (fromAccountId) accountChecks.push({ id: fromAccountId, label: "source" });
if (toAccountId) accountChecks.push({ id: toAccountId, label: "destination" });
const accountOwners = new Map<number, number | null>();
for (const check of accountChecks) {
const account = (await payload
.findByID({
collection: "bank-accounts",
id: check.id as number,
depth: 0,
overrideAccess: true,
})
.catch(() => null)) as unknown as {
id: number;
accountType: string;
ownerUser?: number | { id: number } | null;
status: string;
} | null;
if (!account) {
return { success: false, error: `${check.label} account not found.` };
}
if (account.status === "closed") {
return { success: false, error: `${check.label} account is closed.` };
}
const ownerId =
typeof account.ownerUser === "object" ? account.ownerUser?.id : account.ownerUser;
accountOwners.set(check.id as number, ownerId ?? null);
if (account.accountType === "personal") {
if (!manager && ownerId !== user.id) {
return {
success: false,
error: "You can only move funds on your own personal account.",
};
}
} else if (!manager) {
return {
success: false,
error: "Insufficient permissions. Logistics personnel or higher can move unit funds.",
};
}
}
if (type === "transfer" && (!fromAccountId || !toAccountId)) {
return {
success: false,
error: "A transfer requires both a source and a destination account.",
};
}
if (type === "deposit" && !toAccountId) {
return { success: false, error: "A deposit requires a destination account." };
}
if (type === "withdrawal" && !fromAccountId) {
return { success: false, error: "A withdrawal requires a source account." };
}
const transaction = await applyTransaction(payload, {
type,
fromAccountId,
toAccountId,
amount,
memo,
actorId: user.id as number,
});
const currency = await getMainCurrencyName(payload);
const suffix = currency ? ` ${currency}` : "";
const amountLabel = `${amount.toLocaleString()}${suffix}`;
const eventType =
type === "deposit"
? EventTypes.FinanceDeposit
: type === "withdrawal"
? EventTypes.FinanceWithdraw
: EventTypes.FinanceTransfer;
const eventMessage =
type === "deposit"
? `Deposited ${amountLabel} into account #${toAccountId}`
: type === "withdrawal"
? `Withdrew ${amountLabel} from account #${fromAccountId}`
: `Transferred ${amountLabel} from account #${fromAccountId} to account #${toAccountId}`;
await emitGameEvent(payload, {
type: eventType,
message: eventMessage,
actor: user.id,
targetCollection: "bank-transactions",
targetId: transaction.id,
data: { fromAccountId, toAccountId, amount, type },
});
const notifications: { userId: number; message: string; link: string }[] = [];
if (type === "deposit" && toAccountId) {
const ownerId = accountOwners.get(toAccountId) ?? null;
if (ownerId != null) {
notifications.push({
userId: ownerId,
message: `Deposited ${amountLabel} into your account (#${toAccountId}).`,
link: `/logistics/banking/${toAccountId}`,
});
}
} else if (type === "withdrawal" && fromAccountId) {
const ownerId = accountOwners.get(fromAccountId) ?? null;
if (ownerId != null) {
notifications.push({
userId: ownerId,
message: `Withdrew ${amountLabel} from your account (#${fromAccountId}).`,
link: `/logistics/banking/${fromAccountId}`,
});
}
} else if (type === "transfer" && fromAccountId && toAccountId) {
const fromOwner = accountOwners.get(fromAccountId) ?? null;
const toOwner = accountOwners.get(toAccountId) ?? null;
if (fromOwner != null) {
notifications.push({
userId: fromOwner,
message: `Transferred ${amountLabel} from your account (#${fromAccountId}) to account #${toAccountId}.`,
link: `/logistics/banking/${fromAccountId}`,
});
}
if (toOwner != null && toOwner !== fromOwner) {
notifications.push({
userId: toOwner,
message: `Received ${amountLabel} in account #${toAccountId} from account #${fromAccountId}.`,
link: `/logistics/banking/${toAccountId}`,
});
}
}
const notifType =
type === "deposit"
? "finance:deposit"
: type === "withdrawal"
? "finance:withdraw"
: "finance:transfer";
const notifTitle =
type === "deposit"
? "Bank deposit"
: type === "withdrawal"
? "Bank withdrawal"
: "Bank transfer";
for (const n of notifications) {
await notifyUser(payload, {
userId: n.userId,
type: notifType,
title: notifTitle,
message: n.message,
link: n.link,
});
}
return { success: true, data: transaction.id };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}