payments-dashboard/src/expenseReceiver.js
Adam Moussa b8709abae4 Merge expense-approval-bot into payments-dashboard
Port the Slack reaction-driven expense routing workflow (receiver +
processor) from expense-approval-bot into this stack as JavaScript ESM.
Secrets copied to payments-dashboard/ prefix in Secrets Manager.
2026-05-12 12:18:38 -04:00

79 lines
2.3 KiB
JavaScript

import crypto from "node:crypto";
import {
SecretsManagerClient,
GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda";
const secrets = new SecretsManagerClient();
const lambda = new LambdaClient();
const EXPENSE_PROCESSOR_FN = process.env.EXPENSE_PROCESSOR_FN;
const EXPENSE_SIGNING_SECRET_NAME = process.env.EXPENSE_SIGNING_SECRET_NAME;
let cachedSigningSecret;
async function getSigningSecret() {
if (cachedSigningSecret) return cachedSigningSecret;
const { SecretString } = await secrets.send(
new GetSecretValueCommand({ SecretId: EXPENSE_SIGNING_SECRET_NAME })
);
cachedSigningSecret = SecretString;
return cachedSigningSecret;
}
function verifySignature(body, timestamp, signature, secret) {
if (!timestamp || !signature) return false;
const ts = Number(timestamp);
if (!Number.isFinite(ts)) return false;
if (Math.abs(Date.now() / 1000 - ts) > 300) return false;
const base = `v0:${timestamp}:${body}`;
const expected =
"v0=" + crypto.createHmac("sha256", secret).update(base).digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
export const handler = async (event) => {
let body = event.body || "";
if (event.isBase64Encoded) {
body = Buffer.from(body, "base64").toString("utf-8");
}
const headers = Object.fromEntries(
Object.entries(event.headers || {}).map(([k, v]) => [k.toLowerCase(), v])
);
const timestamp = headers["x-slack-request-timestamp"] || "";
const signature = headers["x-slack-signature"] || "";
const secret = await getSigningSecret();
if (!verifySignature(body, timestamp, signature, secret)) {
console.log("Signature verification failed");
return { statusCode: 401, body: "unauthorized" };
}
const payload = JSON.parse(body);
if (payload.type === "url_verification") {
return {
statusCode: 200,
headers: { "Content-Type": "text/plain" },
body: payload.challenge || "",
};
}
if (payload.type === "event_callback") {
await lambda.send(
new InvokeCommand({
FunctionName: EXPENSE_PROCESSOR_FN,
InvocationType: "Event",
Payload: JSON.stringify(payload.event),
})
);
}
return { statusCode: 200, body: "" };
};