feat(slack): answer the order nudge in Slack, without opening the app
ci / lint-test (push) Successful in 40s

Being sent to a web app to answer "was this shared?" is enough friction
that the question stops getting answered — which is the exact failure the
nudge exists to prevent. So the buttons now act in place: pressing
"Shared 50/50" writes the transaction_splits row and edits the message,
and the app is never opened.

Slack does NOT reach this route directly. It posts to an n8n webhook that
forwards the raw body and signature headers here (user's suggestion).
That is the better shape: n8n already terminates public webhooks, so the
app keeps its blanket OAuth chain and gains no internet-facing
unauthenticated route, and no Traefik change is needed. n8n cannot verify
the signature itself — its Code sandbox has no `require`, so no `crypto`.

Two independent gates, both failing closed: the shared x-ingest-token
(came from n8n) and Slack's v0 signature over the raw body (came from
Slack, not replayed within 5 minutes). An unset signing secret rejects
everything rather than waving it through, because the alternative turns a
misconfigured deploy into an open write endpoint.

An unmapped Slack user is refused 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.

Block Kit is built in the app (ingest returns slack_blocks) rather than
in n8n expressions: a template string is untestable, and this shape has
to stay in step with what the interactive endpoint re-renders after each
press. Null when there is no transaction yet — a card-settled order is
parked until its statement arrives, so there is nothing to split or rate.

Also: /transactions now honours ?q=, so the link lands on the row instead
of the top of an unfiltered ledger.
This commit is contained in:
2026-07-28 15:50:06 +10:00
parent 66a6a51fb8
commit aaa36dd75e
5 changed files with 454 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
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.
*/
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;
}