Files
finance-app/src/lib/slack-verify.ts
T
siddharthd 9f0f38449b
ci / lint-test (push) Successful in 43s
feat(slack): ask the other person too, and put rating before sharing
Two halves of the same requirement, one of which was quietly missing.

Sharing split the money but never reached her: she is not in #smarthome,
so the card whose caption said "both verdicts welcome" was one she could
not see. Now a share DMs her a card of her own. A DM rather than adding
her to the channel, so her surface stays "orders that concern me"
instead of the whole house's ops feed. She was already in SLACK_USER_MAP,
so her press files under participant 4.

Only on the press that turns sharing ON, and only when someone else did
the sharing. Re-notifying on every later rating press would turn one
shared meal into a stream of DMs, which is how a nudge gets muted.

Her card carries no share button: she is being told it was shared, not
asked to decide, and two people toggling one split from separate copies
of a card is a race with no upside.

The app still holds no Slack bot token — it returns a notify instruction
and n8n sends it, the same shape as the modal open. If SLACK_USER_MAP has
no id for her the DM is skipped silently: the split is correct and
complete either way, and failing the press over an unaddressable nudge
would be the worse trade.

Card reordered to rate -> details -> share. You judge the food, then
decide who pays for it; asking "was this shared?" first inverts the
order a person thinks in. The status caption moved under the share button
it describes rather than sitting orphaned mid-card.
2026-07-28 18:33:25 +10:00

71 lines
2.7 KiB
TypeScript

import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Slack request signature verification.
*
* This endpoint is the one route in the app that is NOT behind Traefik's OAuth
* chain — it has to be, because Slack posts to it as a machine with no browser
* session. The signature IS the authentication, so it is not optional and it
* fails closed: an unset signing secret rejects everything rather than waving
* requests through, which is the failure mode that would quietly expose split
* writes to the open internet.
*
* The v0 scheme signs `v0:<timestamp>:<raw body>`. It must be the RAW body —
* re-serialising the parsed form changes the bytes and every signature fails.
*/
export function verifySlackSignature(
rawBody: string,
timestamp: string | null,
signature: string | null
): boolean {
const secret = process.env.SLACK_SIGNING_SECRET;
if (!secret || !timestamp || !signature) return false;
// Replay window. Slack recommends 5 minutes; a captured request is otherwise
// valid forever, and these actions move money between people.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return false;
const expected =
"v0=" +
createHmac("sha256", secret)
.update(`v0:${timestamp}:${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
// timingSafeEqual throws on length mismatch, which is itself a leak of one
// bit; check length first and return the same false either way.
return a.length === b.length && timingSafeEqual(a, b);
}
/**
* Which participant pressed the button.
*
* SLACK_USER_MAP is `<slack user id>:<participant id>` pairs, comma separated.
* An unknown Slack user is rejected rather than defaulted to the owner: in a
* two-person household a wrong attribution is not a rounding error, it is the
* other person's opinion recorded under your name.
*/
/** The reverse: which Slack user is this participant, for DMing them. */
export function slackUserForParticipant(participantId: number): string | null {
const map = process.env.SLACK_USER_MAP ?? "";
for (const pair of map.split(",")) {
const [slack, participant] = pair.split(":").map((s) => s.trim());
if (slack && Number(participant) === participantId) return slack;
}
return null;
}
export function participantForSlackUser(slackUserId: string): number | null {
const map = process.env.SLACK_USER_MAP ?? "";
for (const pair of map.split(",")) {
const [slack, participant] = pair.split(":").map((s) => s.trim());
if (slack && slack === slackUserId && participant) {
const id = Number(participant);
if (Number.isInteger(id)) return id;
}
}
return null;
}