ci / lint-test (push) Successful in 43s
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.
71 lines
2.7 KiB
TypeScript
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;
|
|
}
|