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
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createHmac } from "node:crypto";
import { verifySlackSignature, participantForSlackUser } from "@/lib/slack-verify";
/**
* The signature is one of the two gates on a route that writes splits. Getting
* it wrong is not a cosmetic bug — it is an unauthenticated write path — so the
* fail-closed cases are tested first and explicitly.
*/
const SECRET = "test-signing-secret";
function sign(body: string, ts: string, secret = SECRET) {
return "v0=" + createHmac("sha256", secret).update(`v0:${ts}:${body}`).digest("hex");
}
const now = () => String(Math.floor(Date.now() / 1000));
beforeEach(() => {
process.env.SLACK_SIGNING_SECRET = SECRET;
});
afterEach(() => {
vi.useRealTimers();
});
describe("verifySlackSignature", () => {
it("accepts a correctly signed request", () => {
const ts = now();
const body = "payload=%7B%22type%22%3A%22block_actions%22%7D";
expect(verifySlackSignature(body, ts, sign(body, ts))).toBe(true);
});
it("rejects when the signing secret is unset", () => {
const ts = now();
const body = "x=1";
const sig = sign(body, ts);
delete process.env.SLACK_SIGNING_SECRET;
// Fails CLOSED. An unset secret waving requests through would turn a
// misconfigured deploy into an open write endpoint.
expect(verifySlackSignature(body, ts, sig)).toBe(false);
});
it("rejects a tampered body", () => {
const ts = now();
const sig = sign("payload=original", ts);
expect(verifySlackSignature("payload=tampered", ts, sig)).toBe(false);
});
it("rejects a signature made with a different secret", () => {
const ts = now();
const body = "x=1";
expect(verifySlackSignature(body, ts, sign(body, ts, "wrong-secret"))).toBe(false);
});
it("rejects a replay outside the 5 minute window", () => {
const old = String(Math.floor(Date.now() / 1000) - 400);
const body = "x=1";
expect(verifySlackSignature(body, old, sign(body, old))).toBe(false);
});
it("accepts inside the window", () => {
const recent = String(Math.floor(Date.now() / 1000) - 60);
const body = "x=1";
expect(verifySlackSignature(body, recent, sign(body, recent))).toBe(true);
});
it("rejects missing headers", () => {
expect(verifySlackSignature("x=1", null, "v0=abc")).toBe(false);
expect(verifySlackSignature("x=1", now(), null)).toBe(false);
});
it("rejects a signature of the wrong length without throwing", () => {
// timingSafeEqual throws on length mismatch; a truncated signature must be
// a plain false, not a 500.
expect(() => verifySlackSignature("x=1", now(), "v0=short")).not.toThrow();
expect(verifySlackSignature("x=1", now(), "v0=short")).toBe(false);
});
});
describe("participantForSlackUser", () => {
beforeEach(() => {
process.env.SLACK_USER_MAP = "U111:1, U444:4";
});
it("maps known users", () => {
expect(participantForSlackUser("U111")).toBe(1);
expect(participantForSlackUser("U444")).toBe(4);
});
it("returns null for an unknown user rather than defaulting to the owner", () => {
// A wrong attribution records the other person's opinion under your name,
// which is worse than refusing.
expect(participantForSlackUser("U999")).toBeNull();
});
it("returns null when the map is unset", () => {
delete process.env.SLACK_USER_MAP;
expect(participantForSlackUser("U111")).toBeNull();
});
});
+19
View File
@@ -12,6 +12,7 @@ import {
type MessageMeta, type MessageMeta,
} from "@/lib/order-ingestion"; } from "@/lib/order-ingestion";
import { merchantVerdict } from "@/lib/order-reviews"; import { merchantVerdict } from "@/lib/order-reviews";
import { nudgeBlocks } from "@/lib/slack-blocks";
/** /**
* Machine ingest endpoint for order receipts. * Machine ingest endpoint for order receipts.
@@ -99,6 +100,24 @@ export async function POST(req: NextRequest) {
counts: verdict.counts, counts: verdict.counts,
last_note: verdict.history.find((h) => h.note)?.note ?? null, last_note: verdict.history.find((h) => h.note)?.note ?? null,
}, },
// The nudge message is built here, not in n8n expressions: Block Kit in a
// template string is untestable, and this shape has to stay in step with
// what /api/slack/interactive renders after a button press. Null when
// there is no transaction to act on — a card-settled order is parked
// until its statement arrives, so there is nothing yet to split or rate.
slack_blocks:
result.transactionId && !result.skipped
? nudgeBlocks({
transactionId: result.transactionId,
merchant: order.merchant_name,
currency: order.currency,
total: Number(order.totals.total_charged),
isFamily: order.is_family,
shared: false,
warn: verdict?.warn ?? false,
warnNote: verdict?.history.find((h) => h.note)?.note ?? null,
})
: null,
}); });
} catch (e) { } catch (e) {
// Not a receipt: promotions, delivery updates, adjustment and refund // Not a receipt: promotions, delivery updates, adjustment and refund
+181
View File
@@ -0,0 +1,181 @@
import { NextRequest, NextResponse } from "next/server";
import { queryRaw, queryRow } from "@/lib/db";
import { verifySlackSignature, participantForSlackUser } from "@/lib/slack-verify";
import { nudgeBlocks } from "@/lib/slack-blocks";
import {
RATINGS,
SECOND_CONSUMER_ID,
merchantVerdict,
type Rating,
} from "@/lib/order-reviews";
/**
* Slack button presses on the order nudge.
*
* Pressing "Shared 50/50" splits the transaction here and now and edits the
* message in place. It deliberately does NOT link back to the app: being sent
* to a web app to answer a yes/no question is enough friction that the question
* stops getting answered (user, 2026-07-28).
*
* **Slack does not reach this route directly.** It posts to an n8n webhook,
* which forwards the raw body and Slack's signature headers here. That was the
* user's suggestion (2026-07-28) and it is the better shape: n8n already
* terminates public webhooks, so the app keeps its blanket OAuth chain and
* gains no internet-facing unauthenticated route. n8n cannot do the verifying
* itself — its Code node sandbox has no `require`, so no `crypto`.
*
* Two independent gates, both fail closed:
* 1. `x-ingest-token`, proving the call came from n8n over the internal
* network. Same shared secret as the order ingest route.
* 2. Slack's v0 request signature over the forwarded raw body, proving the
* payload really came from Slack and is not a replay.
*
* Returning a message body directly replaces the original message. That is why
* there is no call to `response_url`: the reply IS the update, and it keeps the
* whole interaction inside Slack's 3-second budget.
*/
export async function POST(req: NextRequest) {
const expected = process.env.ORDER_INGEST_TOKEN;
if (!expected || req.headers.get("x-ingest-token") !== expected) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Must be the RAW body Slack signed — re-serialising the parsed form changes
// the bytes and every signature check fails. n8n forwards it verbatim.
const raw = await req.text();
if (
!verifySlackSignature(
raw,
req.headers.get("x-slack-request-timestamp"),
req.headers.get("x-slack-signature")
)
) {
return NextResponse.json({ error: "bad signature" }, { status: 401 });
}
const payloadRaw = new URLSearchParams(raw).get("payload");
if (!payloadRaw) return NextResponse.json({ error: "no payload" }, { status: 400 });
const payload = JSON.parse(payloadRaw);
if (payload.type !== "block_actions") return new NextResponse(null, { status: 200 });
const action = payload.actions?.[0];
const [idStr, verb, arg] = String(action?.value ?? "").split(":");
const transactionId = Number(idStr);
if (!Number.isInteger(transactionId)) {
return NextResponse.json({ text: "Could not tell which order that was." });
}
const participantId = participantForSlackUser(payload.user?.id ?? "");
if (!participantId) {
// Ephemeral: only the presser sees it, so an unmapped colleague does not
// rewrite the shared message for everyone.
return NextResponse.json({
response_type: "ephemeral",
replace_original: false,
text: `I don't know which participant ${payload.user?.id} is — add them to SLACK_USER_MAP.`,
});
}
if (verb === "share") {
await toggleShare(transactionId);
} else if (verb === "rate" && RATINGS.includes(arg as Rating)) {
await setRating(transactionId, participantId, arg as Rating);
}
const state = await nudgeState(transactionId);
if (!state) return NextResponse.json({ text: "That order is no longer in the ledger." });
return NextResponse.json({
replace_original: true,
blocks: nudgeBlocks(state),
// Notification text for clients that cannot render blocks.
text: `${state.merchant}${state.currency} ${state.total.toFixed(2)}`,
});
}
/**
* Share or unshare, as a real 50/50 split.
*
* The split IS the record that an order was shared, so there is no separate
* flag to keep in step. Safe to clear on an ingested order because such a row
* is post-cutover by construction — the DB CHECK forbids credits orders before
* 2026-01-09 — so there is no settled historical obligation to lose.
*/
async function toggleShare(transactionId: number) {
const existing = await queryRaw<{ participant_id: number }>(
`SELECT participant_id FROM transaction_splits WHERE transaction_id = $1`,
[transactionId]
);
if (existing.some((e) => e.participant_id === SECOND_CONSUMER_ID)) {
await queryRaw(`DELETE FROM transaction_splits WHERE transaction_id = $1`, [
transactionId,
]);
return;
}
await queryRaw(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)
ON CONFLICT (transaction_id, participant_id)
DO UPDATE SET share_percent = 50`,
[transactionId, SECOND_CONSUMER_ID]
);
}
async function setRating(transactionId: number, participantId: number, rating: Rating) {
await queryRaw(
`INSERT INTO order_reviews (transaction_id, participant_id, rating, order_again)
VALUES ($1, $2, $3, $4)
ON CONFLICT (transaction_id, participant_id) DO UPDATE
SET rating = EXCLUDED.rating,
order_again = EXCLUDED.order_again,
updated_at = now()`,
[transactionId, participantId, rating, rating !== "never"]
);
}
/** Everything the refreshed message needs, read back after the write. */
async function nudgeState(transactionId: number) {
const row = await queryRow<{
merchant: string | null;
currency: string | null;
amount: string;
}>(
`SELECT em.merchant_normalized AS merchant,
COALESCE(em.currency, 'AUD') AS currency,
t.amount
FROM transactions t
LEFT JOIN expense_metadata em
ON em.transaction_id = t.id OR em.matched_transaction_id = t.id
WHERE t.id = $1`,
[transactionId]
);
if (!row) return null;
const [splits, ratings, verdict] = await Promise.all([
queryRaw<{ participant_id: number }>(
`SELECT participant_id FROM transaction_splits WHERE transaction_id = $1`,
[transactionId]
),
queryRaw<{ name: string; rating: Rating }>(
`SELECT p.name, r.rating FROM order_reviews r
JOIN participants p ON p.id = r.participant_id
WHERE r.transaction_id = $1 AND r.rating IS NOT NULL
ORDER BY r.participant_id`,
[transactionId]
),
merchantVerdict(row.merchant, transactionId),
]);
return {
transactionId,
merchant: row.merchant ?? "Unknown merchant",
currency: row.currency ?? "AUD",
total: Number(row.amount),
shared: splits.some((s) => s.participant_id === SECOND_CONSUMER_ID),
ratings,
warn: verdict?.warn ?? false,
warnNote: verdict?.history.find((h) => h.note)?.note ?? null,
};
}
+95
View File
@@ -0,0 +1,95 @@
import { RATINGS, type Rating } from "@/lib/order-reviews";
/**
* The order nudge as Block Kit, so the answer happens IN Slack.
*
* A link to the app was the first version and it was the wrong shape: being
* sent to a web app to answer "was this shared?" is enough friction that the
* question stops getting answered, which is the whole failure the nudge exists
* to prevent (user, 2026-07-28).
*
* Buttons carry `<transactionId>:<verb>` in `value`. The transaction id has to
* travel in the payload because Slack gives the handler nothing else to
* identify the row — the message text is not a key.
*/
export interface NudgeState {
transactionId: number;
merchant: string;
currency: string;
total: number;
isFamily?: boolean;
shared: boolean;
/** Ratings already recorded, as participant name → rating. */
ratings?: { name: string; rating: Rating }[];
warn?: boolean;
warnNote?: string | null;
}
const RATING_LABEL: Record<Rating, string> = {
loved: "Loved it",
liked: "Liked it",
ok: "OK",
never: "Never again",
};
export function nudgeBlocks(s: NudgeState) {
const lines = [
`:receipt: *${s.merchant}* — ${s.currency} ${s.total.toFixed(2)}` +
(s.isFamily ? " · [Family]" : ""),
];
if (s.warn) {
lines.push(
`:warning: You marked this merchant *never again* before` +
(s.warnNote ? ` — _${s.warnNote}_` : "")
);
}
if (s.ratings?.length) {
lines.push(s.ratings.map((r) => `${r.name}: *${RATING_LABEL[r.rating]}*`).join(" · "));
}
return [
{ type: "section", text: { type: "mrkdwn", text: lines.join("\n") } },
{
type: "actions",
block_id: "share",
elements: [
{
type: "button",
action_id: "share_toggle",
// The label states what pressing it will DO, not the current state.
// A button labelled with its own state reads as already-pressed and
// gets tapped to "fix" it, toggling the thing it was reporting.
text: {
type: "plain_text",
text: s.shared ? "Make it just me" : "Shared 50/50",
},
style: s.shared ? undefined : "primary",
value: `${s.transactionId}:share`,
},
],
},
{
type: "actions",
block_id: "rate",
elements: RATINGS.map((r) => ({
type: "button",
action_id: `rate_${r}`,
text: { type: "plain_text", text: RATING_LABEL[r] },
style: r === "never" ? "danger" : undefined,
value: `${s.transactionId}:rate:${r}`,
})),
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: s.shared
? ":busts_in_silhouette: Split 50/50 — both verdicts welcome"
: ":bust_in_silhouette: Not shared",
},
],
},
];
}
+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;
}