diff --git a/app/api/inbox/[address]/[messageId]/__tests__/d1-reads-flip.test.ts b/app/api/inbox/[address]/[messageId]/__tests__/d1-reads-flip.test.ts new file mode 100644 index 00000000..e71a14d6 --- /dev/null +++ b/app/api/inbox/[address]/[messageId]/__tests__/d1-reads-flip.test.ts @@ -0,0 +1,269 @@ +/** + * Phase 2.5 Step 3.2 — GET /api/inbox/[address]/[messageId] D1 read flip. + * + * Covers: + * 1. 200 — message exists, returned correctly + * 2. 404 — message not found in D1 + * 3. 404 — address-match guard (block-on-merge per #725 / secret-mars v167): + * msg_X has to_btc_address=addr_A; GET .../addr_B/msg_X → 404 + * (not 200 with body — prevents non-auth disclosure regression) + * 4. 503 — D1 throws → structured fallback, not unhandled 500 + * 5. Reply attachment — message with reply → response includes .reply object + * + * See: https://github.com/aibtcdev/landing-page/issues/725 (Step 3.2 spec) + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import { NextRequest } from "next/server"; + +// ---- module mocks (must be declared before route imports) ------------------- + +vi.mock("@opennextjs/cloudflare", () => ({ + getCloudflareContext: vi.fn(), +})); + +vi.mock("@/lib/agent-lookup", () => ({ + lookupAgent: vi.fn(), +})); + +vi.mock("@/lib/inbox/d1-reads", () => ({ + getInboxMessageFromD1: vi.fn(), + fetchRepliesForMessages: vi.fn(), +})); + +vi.mock("@/lib/inbox", () => ({ + getMessage: vi.fn(), + updateMessage: vi.fn(), + validateMarkRead: vi.fn(), + buildMarkReadMessage: vi.fn(() => "Mark as Read | msg_test"), + decrementUnreadCount: vi.fn(), +})); + +vi.mock("@/lib/inbox/d1-dual-write", () => ({ + updateMessageStateD1: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/bitcoin-verify", () => ({ + verifyBitcoinSignature: vi.fn(), +})); + +vi.mock("@/lib/logging", () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), + createConsoleLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), + isLogsRPC: () => false, +})); + +vi.mock("@/lib/env", () => ({ + shouldFailClosed: vi.fn(() => false), +})); + +// ---- imports after mocks ---------------------------------------------------- + +import { GET } from "../route"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { lookupAgent } from "@/lib/agent-lookup"; +import { + getInboxMessageFromD1, + fetchRepliesForMessages, +} from "@/lib/inbox/d1-reads"; + +// ---- shared fixtures -------------------------------------------------------- + +const ADDR_A = "bc1qxj5jtv8jwm7zv2nczn2xfq9agjgj0sqpsxn43h"; +const ADDR_B = "bc1qw0y4ant38zykzjqssgnujqmszruvhkwupvp6dn"; +const MSG_ID = "msg_1778221238475_test_address_match_guard"; + +const AGENT_A = { + btcAddress: ADDR_A, + stxAddress: "SP3JR7JXFT7ZM9JKSQPBQG1HPT0D365MA5TN0P12E", + displayName: "Frosty Narwhal", +}; + +const AGENT_B = { + btcAddress: ADDR_B, + stxAddress: "SP3GXCKM4AB5EB1KJ8V5QSTR1XMTW3R142VQS2NVW", + displayName: "Amber Otter", +}; + +const INBOX_MESSAGE = { + messageId: MSG_ID, + fromAddress: "SP3GXCKM4AB5EB1KJ8V5QSTR1XMTW3R142VQS2NVW", + toBtcAddress: ADDR_A, + toStxAddress: "SP3JR7JXFT7ZM9JKSQPBQG1HPT0D365MA5TN0P12E", + content: "Hello from agent B to agent A", + paymentTxid: "abc123deadbeef", + paymentSatoshis: 100, + sentAt: "2026-05-08T06:20:38.665Z", + authenticated: false, + paymentStatus: "confirmed" as const, +}; + +const REPLY = { + messageId: MSG_ID, + fromAddress: ADDR_A, + toBtcAddress: ADDR_B, + reply: "Thanks for the message!", + signature: "sig_abc123", + repliedAt: "2026-05-08T07:00:00.000Z", +}; + +function makeMockDB() { + const stmt = { + bind: vi.fn().mockReturnThis(), + first: vi.fn(), + all: vi.fn(), + run: vi.fn(), + raw: vi.fn(), + }; + return { + prepare: vi.fn().mockReturnValue(stmt), + batch: vi.fn(), + dump: vi.fn(), + exec: vi.fn(), + } as unknown as D1Database; +} + +function buildGetRequest(address: string, messageId: string): NextRequest { + return new NextRequest( + `https://aibtc.com/api/inbox/${address}/${messageId}`, + { method: "GET" } + ); +} + +function buildContext(address: string, messageId: string) { + return { params: Promise.resolve({ address, messageId }) }; +} + +beforeEach(() => { + vi.clearAllMocks(); + + (getCloudflareContext as Mock).mockReturnValue({ + env: { + DB: makeMockDB(), + VERIFIED_AGENTS: {} as KVNamespace, + }, + ctx: { waitUntil: vi.fn() }, + }); +}); + +// ---- tests ------------------------------------------------------------------ + +describe("Phase 2.5 Step 3.2 — GET /api/inbox/[address]/[messageId] D1 flip", () => { + it("returns 200 with message shape when message exists", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + (getInboxMessageFromD1 as Mock).mockResolvedValue(INBOX_MESSAGE); + (fetchRepliesForMessages as Mock).mockResolvedValue(new Map()); + + const res = await GET( + buildGetRequest(ADDR_A, MSG_ID), + buildContext(ADDR_A, MSG_ID) + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.message).toMatchObject({ messageId: MSG_ID, toBtcAddress: ADDR_A }); + expect(body.reply).toBeNull(); + expect(body.recipient).toMatchObject({ btcAddress: ADDR_A }); + }); + + it("returns 404 when message_id not found in D1", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + (getInboxMessageFromD1 as Mock).mockResolvedValue(null); + (fetchRepliesForMessages as Mock).mockResolvedValue(new Map()); + + const res = await GET( + buildGetRequest(ADDR_A, "msg_nonexistent"), + buildContext(ADDR_A, "msg_nonexistent") + ); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("Message not found"); + }); + + it("address-match guard: msg_X exists for addr_A but GET with addr_B returns 404 (not 200)", async () => { + // BLOCK-ON-MERGE per #725 / secret-mars v167. SQL security gate in `getInboxMessageFromD1` (lib/inbox/d1-reads.ts). + (lookupAgent as Mock).mockResolvedValue(AGENT_B); + // D1 returns null because ADDR_B does not match ADDR_A in the WHERE clause + (getInboxMessageFromD1 as Mock).mockResolvedValue(null); + + const res = await GET( + buildGetRequest(ADDR_B, MSG_ID), + buildContext(ADDR_B, MSG_ID) + ); + + // MUST be 404, not 200 — a 200 would be a non-auth disclosure + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("Message not found"); + + // Verify the D1 query was called with ADDR_B (not ADDR_A), confirming + // the AND clause will never match ADDR_A's message for ADDR_B's query + expect(getInboxMessageFromD1).toHaveBeenCalledOnce(); + const [, calledAddress, calledMessageId] = (getInboxMessageFromD1 as Mock).mock.calls[0]; + expect(calledAddress).toBe(ADDR_B); + expect(calledMessageId).toBe(MSG_ID); + }); + + it("returns 503 with structured body when D1 throws — not unhandled 500", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + (getInboxMessageFromD1 as Mock).mockRejectedValue( + new Error("D1_ERROR: connection reset") + ); + + const res = await GET( + buildGetRequest(ADDR_A, MSG_ID), + buildContext(ADDR_A, MSG_ID) + ); + + expect(res.status).toBe(503); + expect(res.status).not.toBe(500); + const body = await res.json(); + expect(body).toMatchObject({ + error: "transient_d1_unavailable", + retry_after: 5, + }); + expect(body.message).toMatch(/temporarily unavailable/i); + expect(res.headers.get("Retry-After")).toBe("5"); + }); + + it("returns 503 when fetchRepliesForMessages throws after message found", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + (getInboxMessageFromD1 as Mock).mockResolvedValue(INBOX_MESSAGE); + (fetchRepliesForMessages as Mock).mockRejectedValue( + new Error("D1_ERROR: schema mismatch") + ); + + const res = await GET( + buildGetRequest(ADDR_A, MSG_ID), + buildContext(ADDR_A, MSG_ID) + ); + + expect(res.status).toBe(503); + const body = await res.json(); + expect(body.error).toBe("transient_d1_unavailable"); + }); + + it("reply attachment: message with reply returns .reply object in response", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + (getInboxMessageFromD1 as Mock).mockResolvedValue({ + ...INBOX_MESSAGE, + repliedAt: "2026-05-08T07:00:00.000Z", + }); + const repliesMap = new Map([[MSG_ID, REPLY]]); + (fetchRepliesForMessages as Mock).mockResolvedValue(repliesMap); + + const res = await GET( + buildGetRequest(ADDR_A, MSG_ID), + buildContext(ADDR_A, MSG_ID) + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.reply).not.toBeNull(); + expect(body.reply).toMatchObject({ + messageId: MSG_ID, + reply: "Thanks for the message!", + }); + }); +}); diff --git a/app/api/inbox/[address]/[messageId]/route.ts b/app/api/inbox/[address]/[messageId]/route.ts index 5b110235..7c758c70 100644 --- a/app/api/inbox/[address]/[messageId]/route.ts +++ b/app/api/inbox/[address]/[messageId]/route.ts @@ -9,15 +9,18 @@ import { verifyBitcoinSignature } from "@/lib/bitcoin-verify"; import { lookupAgent } from "@/lib/agent-lookup"; import { getMessage, - getReply, updateMessage, - getAgentInbox, validateMarkRead, buildMarkReadMessage, decrementUnreadCount, } from "@/lib/inbox"; import { shouldFailClosed } from "@/lib/env"; import { updateMessageStateD1 } from "@/lib/inbox/d1-dual-write"; +import { + getInboxMessageFromD1, + fetchRepliesForMessages, +} from "@/lib/inbox/d1-reads"; +import type { InboxMessage, OutboxReply } from "@/lib/inbox/types"; /** Retry-After value (seconds) to return on 429s — matches the 60s binding window. */ const RATE_LIMIT_RETRY_AFTER = 60; @@ -44,35 +47,35 @@ export async function GET( ); } - // Fetch message and reply in parallel - const [message, reply] = await Promise.all([ - getMessage(kv, messageId), - getReply(kv, messageId), - ]); - - if (!message) { + // Phase 2.5 Step 3.2 — D1 read flip. Security gate (composite WHERE message_id = ? AND to_btc_address = ?) in `getInboxMessageFromD1` (lib/inbox/d1-reads.ts); see #725 block-on-merge. + let message: InboxMessage | null; + let repliesMap: Map; + try { + const db = env.DB as D1Database; + message = await getInboxMessageFromD1(db, agent.btcAddress, messageId); + if (!message) { + return NextResponse.json( + { + error: "Message not found", + messageId, + hint: "Check GET /api/inbox/[address] to see available messages", + }, + { status: 404 } + ); + } + repliesMap = await fetchRepliesForMessages(db, [messageId]); + } catch (e) { return NextResponse.json( { - error: "Message not found", - messageId, - hint: "Check GET /api/inbox/[address] to see available messages", + error: "transient_d1_unavailable", + message: "Inbox database temporarily unavailable. Please retry shortly.", + retry_after: 5, }, - { status: 404 } + { status: 503, headers: { "Retry-After": "5" } } ); } - // Verify message belongs to this agent (compare resolved BTC address) - if (message.toBtcAddress !== agent.btcAddress) { - return NextResponse.json( - { - error: "Message does not belong to this address", - messageId, - expectedAddress: message.toBtcAddress, - providedAddress: address, - }, - { status: 400 } - ); - } + const reply = repliesMap.get(messageId) ?? null; // Resolve sender agent info (recipient is already `agent`) const senderAddr = message.senderBtcAddress || message.fromAddress; @@ -83,7 +86,7 @@ export async function GET( return NextResponse.json( { message, - reply: reply || null, + reply, sender: senderAgent ? { btcAddress: senderAgent.btcAddress, stxAddress: senderAgent.stxAddress, displayName: senderAgent.displayName } : null, @@ -101,6 +104,9 @@ export async function GET( ); } +// PATCH (mark-read) intentionally still writes to KV alongside the D1 dual-write +// from #720. Phase 2.5 Step 4 (#730) removes the KV writes; until then, KV remains +// authoritative for the write path so a Step 3.x rollback is bounded. export async function PATCH( request: NextRequest, { params }: { params: Promise<{ address: string; messageId: string }> } diff --git a/lib/inbox/d1-reads.ts b/lib/inbox/d1-reads.ts index 5db58cb4..3c32a549 100644 --- a/lib/inbox/d1-reads.ts +++ b/lib/inbox/d1-reads.ts @@ -2,6 +2,7 @@ * D1 read helpers for GET /api/inbox/[address] and sibling routes. * * Phase 2.5 Step 3.1 — flip inbox-list GET from KV reads to D1 SELECTs. + * Phase 2.5 Step 3.2 — adds getInboxMessageFromD1 for the single-message GET. * KV writes are NOT removed here (that is Step 4). * * These helpers query the inbox_messages table that is being populated by @@ -19,6 +20,7 @@ * (auth'd-branch separation / private no-store / pre-gate cache safety) * * See: https://github.com/aibtcdev/landing-page/issues/721 (Step 3.1 spec) + * See: https://github.com/aibtcdev/landing-page/issues/725 (Step 3.2 spec) * See: https://github.com/aibtcdev/landing-page/issues/697 (Phase 2.5 umbrella) * See: https://github.com/aibtcdev/landing-page/issues/723 (cache-invariant extraction) */ @@ -223,5 +225,45 @@ export async function fetchRepliesForMessages( return map; } +/** + * Fetch a single inbound message by messageId AND btcAddress from D1. + * + * The AND clause on to_btc_address is the security gate: it prevents a caller + * from fetching a message that belongs to a different address (address-match + * guard — Step 3.2 block-on-merge per issue #725 / secret-mars v167 elevation). + * + * SQL shape: + * SELECT … FROM inbox_messages + * WHERE message_id = ? AND to_btc_address = ? AND is_reply = 0 + * + * Returns null when: + * - message_id does not exist + * - message_id exists but to_btc_address does not match (→ 404, not 400/200) + */ +export async function getInboxMessageFromD1( + db: D1Database, + btcAddress: string, + messageId: string +): Promise { + const sql = ` + SELECT + message_id, from_stx_address, to_btc_address, to_stx_address, + content, payment_txid, payment_satoshis, payment_status, + payment_id, receipt_id, recovered_via_txid, authenticated, + bitcoin_signature, sender_btc_address, + sent_at, read_at, replied_at, reply_to_message_id + FROM inbox_messages + WHERE message_id = ? AND to_btc_address = ? AND is_reply = 0 + `; + + const row = await db + .prepare(sql) + .bind(messageId, btcAddress) + .first(); + + if (!row) return null; + return rowToInboxMessage(row); +} + // Re-export the prefix so tests can verify synthesized IDs export { REPLY_D1_PK_PREFIX };