diff --git a/src/__tests__/unit/slack-verify.test.ts b/src/__tests__/unit/slack-verify.test.ts new file mode 100644 index 0000000..45fc163 --- /dev/null +++ b/src/__tests__/unit/slack-verify.test.ts @@ -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(); + }); +}); diff --git a/src/app/api/orders/ingest/route.ts b/src/app/api/orders/ingest/route.ts index 01259c2..dde5cd7 100644 --- a/src/app/api/orders/ingest/route.ts +++ b/src/app/api/orders/ingest/route.ts @@ -12,6 +12,7 @@ import { type MessageMeta, } from "@/lib/order-ingestion"; import { merchantVerdict } from "@/lib/order-reviews"; +import { nudgeBlocks } from "@/lib/slack-blocks"; /** * Machine ingest endpoint for order receipts. @@ -99,6 +100,24 @@ export async function POST(req: NextRequest) { counts: verdict.counts, 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) { // Not a receipt: promotions, delivery updates, adjustment and refund diff --git a/src/app/api/slack/interactive/route.ts b/src/app/api/slack/interactive/route.ts new file mode 100644 index 0000000..9f9fd31 --- /dev/null +++ b/src/app/api/slack/interactive/route.ts @@ -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, + }; +} diff --git a/src/lib/slack-blocks.ts b/src/lib/slack-blocks.ts new file mode 100644 index 0000000..a37ab85 --- /dev/null +++ b/src/lib/slack-blocks.ts @@ -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 `:` 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 = { + 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", + }, + ], + }, + ]; +} diff --git a/src/lib/slack-verify.ts b/src/lib/slack-verify.ts new file mode 100644 index 0000000..c6e1c14 --- /dev/null +++ b/src/lib/slack-verify.ts @@ -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::`. 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 `:` 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; +}