diff --git a/app/api/inbox/[address]/__tests__/d1-sentcount-partners.test.ts b/app/api/inbox/[address]/__tests__/d1-sentcount-partners.test.ts new file mode 100644 index 00000000..fe725434 --- /dev/null +++ b/app/api/inbox/[address]/__tests__/d1-sentcount-partners.test.ts @@ -0,0 +1,437 @@ +/** + * Phase 2.5 Step 3.3 — sentCount restoration + partners-with-sent tests. + * + * Covers: + * 1. sentCount restoration: inbox-list GET returns sentCount > 0 when + * countOutboxRepliesFromD1 returns a positive value + * (was "const sentCount = 0" in Step 3.1) + * 2. partners-with-sent: partner graph includes both inbound senders (received) + * AND addresses this agent has replied to (sent) + * 3. partners-received-only: when no sent replies exist, partners still computed + * from received messages + * 4. D1-throws fallback: even with the extra countOutboxRepliesFromD1 in + * Promise.all, D1 throws still produce 503 + Retry-After: 5 + * + * See: https://github.com/aibtcdev/landing-page/issues/728 (Step 3.3 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", () => ({ + listInboxMessagesFromD1: vi.fn(), + countInboxMessagesFromD1: vi.fn(), + fetchRepliesForMessages: vi.fn(), + listOutboxRepliesFromD1: vi.fn(), + countOutboxRepliesFromD1: vi.fn(), +})); + +vi.mock("@/lib/cache", () => ({ + invalidateAgentListCache: 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/inbox", () => ({ + validateInboxMessage: vi.fn(), + verifyInboxPayment: vi.fn(), + verifyTxidPayment: vi.fn(), + storeMessage: vi.fn(), + storeStagedInboxPayment: vi.fn(), + updateAgentInbox: vi.fn(), + updateSentIndex: vi.fn(), + INBOX_PRICE_SATS: 100, + REDEEMED_TXID_TTL_SECONDS: 7776000, + RELAY_CIRCUIT_BREAKER_RETRY_AFTER_SECONDS: 300, + buildInboxPaymentRequirements: vi.fn(), + buildSenderAuthMessage: vi.fn(), + DEFAULT_RELAY_URL: "https://x402-relay.aibtc.com", + enqueueInboxReconciliation: vi.fn(), +})); + +vi.mock("@/lib/bitcoin-verify", () => ({ + verifyBitcoinSignature: vi.fn(), +})); + +vi.mock("@/lib/inbox/payment-logging", () => ({ + getPaymentRepoVersion: vi.fn().mockReturnValue("1.0.0"), + logPaymentEvent: vi.fn(), +})); + +vi.mock("@/lib/inbox/d1-dual-write", () => ({ + insertInboundMessageToD1: vi.fn().mockResolvedValue(undefined), +})); + +// ---- imports after mocks ---------------------------------------------------- + +import { GET } from "../route"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { lookupAgent } from "@/lib/agent-lookup"; +import { + listInboxMessagesFromD1, + countInboxMessagesFromD1, + fetchRepliesForMessages, + listOutboxRepliesFromD1, + countOutboxRepliesFromD1, +} from "@/lib/inbox/d1-reads"; + +// ---- shared fixtures -------------------------------------------------------- + +const AGENT_ADDR = "bc1qxj5jtv8jwm7zv2nczn2xfq9agjgj0sqpsxn43h"; +const SENDER_ADDR = "bc1qp66jvxe765wgwpzqk8kcrmgh2mucyxg540mtzv"; +const REPLY_TARGET_ADDR = "bc1qw0y4ant38zykzjqssgnujqmszruvhkwupvp6dn"; + +const TEST_AGENT = { + btcAddress: AGENT_ADDR, + stxAddress: "SP3EPDH1E2Y1M4W5GCK4YEJPQ9VW3APJB4Z1QEBNC", + displayName: "Frosty Narwhal", +}; + +const SENDER_AGENT = { + btcAddress: SENDER_ADDR, + stxAddress: "SP4DXVEC16FS6QR7RBKGWZYJKTXPC81W49W0ATJE", + displayName: "Solar Penguin", +}; + +const REPLY_TARGET_AGENT = { + btcAddress: REPLY_TARGET_ADDR, + stxAddress: "SP3GXCKM4AB5EB1KJ8V5QSTR1XMTW3R142VQS2NVW", + displayName: "Amber Otter", +}; + +const RECEIVED_MESSAGE = { + messageId: "msg_1778221238475_received", + fromAddress: "SP4DXVEC16FS6QR7RBKGWZYJKTXPC81W49W0ATJE", // sender STX + toBtcAddress: AGENT_ADDR, + toStxAddress: "SP3EPDH1E2Y1M4W5GCK4YEJPQ9VW3APJB4Z1QEBNC", + content: "Hello agent!", + paymentSatoshis: 100, + sentAt: "2026-05-08T06:00:00.000Z", + authenticated: false, + paymentStatus: "confirmed" as const, +}; + +const SENT_REPLY = { + messageId: "msg_1778221238475_received", // parent ID + fromAddress: AGENT_ADDR, + toBtcAddress: REPLY_TARGET_ADDR, + reply: "Thanks for the message!", + signature: "sig_abc123base64", + repliedAt: "2026-05-08T07:00:00.000Z", +}; + +function buildGetRequest(address: string, query = ""): NextRequest { + return new NextRequest( + `https://aibtc.com/api/inbox/${address}${query}`, + { method: "GET" } + ); +} + +function buildContext(address: string) { + return { params: Promise.resolve({ address }) }; +} + +function setupDefaultMocks() { + (getCloudflareContext as Mock).mockReturnValue({ + env: { + DB: { prepare: vi.fn() } as unknown as D1Database, + VERIFIED_AGENTS: {} as KVNamespace, + }, + ctx: { waitUntil: vi.fn() }, + }); + (lookupAgent as Mock).mockResolvedValue(TEST_AGENT); + (listInboxMessagesFromD1 as Mock).mockResolvedValue([RECEIVED_MESSAGE]); + (countInboxMessagesFromD1 as Mock).mockResolvedValue(1); + (fetchRepliesForMessages as Mock).mockResolvedValue(new Map()); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(0); +} + +beforeEach(() => { + vi.clearAllMocks(); + setupDefaultMocks(); +}); + +// ---- sentCount restoration tests -------------------------------------------- + +describe("Phase 2.5 Step 3.3 — sentCount restoration in inbox-list GET", () => { + it("returns sentCount > 0 when countOutboxRepliesFromD1 returns positive count", async () => { + // Step 3.3 acceptance signal: inbox-list GET must now return real sentCount. + // Was stubbed to 0 in Step 3.1 (const sentCount = 0). + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(3); + + const res = await GET(buildGetRequest(AGENT_ADDR), buildContext(AGENT_ADDR)); + + expect(res.status).toBe(200); + const body = await res.json(); + // sentCount must come from D1 countOutboxRepliesFromD1, not 0 stub + expect(body.inbox.sentCount).toBe(3); + expect(body.inbox.sentCount).toBeGreaterThan(0); + }); + + it("returns sentCount = 0 when agent has sent no replies", async () => { + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(0); + + const res = await GET(buildGetRequest(AGENT_ADDR), buildContext(AGENT_ADDR)); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.inbox.sentCount).toBe(0); + }); + + it("includes sentCount in economics.satsSent calculation", async () => { + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(2); + + const res = await GET(buildGetRequest(AGENT_ADDR), buildContext(AGENT_ADDR)); + + expect(res.status).toBe(200); + const body = await res.json(); + // satsSent = sentCount * INBOX_PRICE_SATS (100 sats each) + expect(body.inbox.economics.satsSent).toBe(2 * 100); + }); + + it("sentCount is included in the empty-inbox self-documenting response", async () => { + // When both totalCount === 0 AND sentCount === 0, the route returns the + // self-doc body. sentCount must still be 0 (not undefined) in that case. + (listInboxMessagesFromD1 as Mock).mockResolvedValue([]); + (countInboxMessagesFromD1 as Mock).mockResolvedValue(0); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(0); + + const res = await GET(buildGetRequest(AGENT_ADDR), buildContext(AGENT_ADDR)); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.inbox.sentCount).toBe(0); + }); + + it("sent-only inbox (totalCount===0 but sentCount>0) returns sentCount in normal envelope, not self-doc", async () => { + // Regression fix: agent has sent replies but has never received a message. + // Before the fix, the totalCount===0 early-return hardcoded sentCount:0 + // and partners:[], so the sent-only path was unreachable. After the fix, + // the self-doc path requires both totalCount===0 AND sentCount===0. + (listInboxMessagesFromD1 as Mock).mockResolvedValue([]); + (countInboxMessagesFromD1 as Mock).mockResolvedValue(0); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(3); + + const res = await GET(buildGetRequest(AGENT_ADDR), buildContext(AGENT_ADDR)); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.inbox.sentCount).toBe(3); + expect(body.inbox.totalCount).toBe(0); + expect(body.inbox.economics.satsSent).toBe(3 * 100); + // Must NOT be the self-doc shape + expect(body.endpoint).toBeUndefined(); + expect(body.howToSend).toBeDefined(); // howToSend is in both shapes; check via other markers + }); + + it("sent-only inbox with partners requested exposes sent-direction partners", async () => { + // Regression check for the partners-with-sent path in the sent-only case. + (lookupAgent as Mock).mockImplementation((kv: unknown, addr: string) => { + if (addr === AGENT_ADDR) return Promise.resolve(TEST_AGENT); + if (addr === REPLY_TARGET_ADDR) return Promise.resolve(REPLY_TARGET_AGENT); + return Promise.resolve(null); + }); + (listInboxMessagesFromD1 as Mock).mockResolvedValue([]); + (countInboxMessagesFromD1 as Mock).mockResolvedValue(0); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([SENT_REPLY]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(1); + + const res = await GET( + buildGetRequest(AGENT_ADDR, "?include=partners"), + buildContext(AGENT_ADDR) + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.inbox.sentCount).toBe(1); + expect(Array.isArray(body.inbox.partners)).toBe(true); + expect(body.inbox.partners.length).toBeGreaterThan(0); + const target = body.inbox.partners.find( + (p: { btcAddress: string }) => p.btcAddress === REPLY_TARGET_ADDR + ); + expect(target).toBeDefined(); + expect(target.direction).toBe("sent"); + }); +}); + +// ---- partners-with-sent tests ----------------------------------------------- + +describe("Phase 2.5 Step 3.3 — partners-with-sent in inbox-list GET", () => { + beforeEach(() => { + // Set up additional agent lookups for partner resolution. + // Must handle both BTC and STX address lookups since the route resolves + // received-message partners via their STX address (fromAddress) and + // sent-reply partners via their BTC address (toBtcAddress). + (lookupAgent as Mock).mockImplementation((kv: unknown, addr: string) => { + if (addr === AGENT_ADDR) return Promise.resolve(TEST_AGENT); + if (addr === SENDER_AGENT.stxAddress) return Promise.resolve(SENDER_AGENT); + if (addr === SENDER_ADDR) return Promise.resolve(SENDER_AGENT); + if (addr === REPLY_TARGET_AGENT.stxAddress) return Promise.resolve(REPLY_TARGET_AGENT); + if (addr === REPLY_TARGET_ADDR) return Promise.resolve(REPLY_TARGET_AGENT); + return Promise.resolve(null); + }); + }); + + it("partners includes both received senders AND sent reply targets when both exist", async () => { + // Mock received messages (partner = sender) + (listInboxMessagesFromD1 as Mock).mockResolvedValue([RECEIVED_MESSAGE]); + (countInboxMessagesFromD1 as Mock).mockResolvedValue(1); + // Mock sent replies (partner = toBtcAddress) + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([SENT_REPLY]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(1); + + const res = await GET( + buildGetRequest(AGENT_ADDR, "?include=partners"), + buildContext(AGENT_ADDR) + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.inbox.partners).toBeDefined(); + + // Should have at least 2 partners: the sender (received) and the reply target (sent) + const partnerAddresses = body.inbox.partners.map((p: { btcAddress: string }) => p.btcAddress); + // The sender is resolved via SENDER_AGENT + expect(partnerAddresses).toContain(SENDER_ADDR); + // The reply target is in toBtcAddress of the sent reply + expect(partnerAddresses).toContain(REPLY_TARGET_ADDR); + }); + + it("partners from sent-only direction have direction='sent'", async () => { + // Only sent replies, no received messages + (listInboxMessagesFromD1 as Mock).mockResolvedValue([]); + (countInboxMessagesFromD1 as Mock).mockResolvedValue(0); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([SENT_REPLY]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(1); + + const res = await GET( + buildGetRequest(AGENT_ADDR, "?include=partners"), + buildContext(AGENT_ADDR) + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.inbox.partners).toBeDefined(); + + const replyTargetPartner = body.inbox.partners.find( + (p: { btcAddress: string }) => p.btcAddress === REPLY_TARGET_ADDR + ); + // Partners from outbox-only should have direction='sent' + if (replyTargetPartner) { + expect(replyTargetPartner.direction).toBe("sent"); + } + }); + + it("partners from both received and sent have direction='both' after merge", async () => { + // Same agent appears as both a sender (received msg) and reply target (sent reply) + const DUAL_PARTNER_ADDR = REPLY_TARGET_ADDR; + const receivedMsgFromDualPartner = { + ...RECEIVED_MESSAGE, + fromAddress: REPLY_TARGET_AGENT.stxAddress, // received from REPLY_TARGET + }; + const sentReplyToDualPartner = { + ...SENT_REPLY, + toBtcAddress: DUAL_PARTNER_ADDR, // also sent reply to REPLY_TARGET + }; + + (listInboxMessagesFromD1 as Mock).mockResolvedValue([receivedMsgFromDualPartner]); + (countInboxMessagesFromD1 as Mock).mockResolvedValue(1); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([sentReplyToDualPartner]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(1); + + const res = await GET( + buildGetRequest(AGENT_ADDR, "?include=partners"), + buildContext(AGENT_ADDR) + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.inbox.partners).toBeDefined(); + + const dualPartner = body.inbox.partners.find( + (p: { btcAddress: string }) => p.btcAddress === DUAL_PARTNER_ADDR + ); + // After dedup + merge, this partner should have direction='both' + if (dualPartner) { + expect(dualPartner.direction).toBe("both"); + } + }); + + it("partners only from received when no sent replies (received-only path still works)", async () => { + (listInboxMessagesFromD1 as Mock).mockResolvedValue([RECEIVED_MESSAGE]); + (countInboxMessagesFromD1 as Mock).mockResolvedValue(1); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([]); // no sent replies + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(0); + + const res = await GET( + buildGetRequest(AGENT_ADDR, "?include=partners"), + buildContext(AGENT_ADDR) + ); + + expect(res.status).toBe(200); + const body = await res.json(); + // Partners should still work from received-only path + expect(Array.isArray(body.inbox.partners)).toBe(true); + // The sender should appear as a received partner + const senderPartner = body.inbox.partners.find( + (p: { btcAddress: string }) => p.btcAddress === SENDER_ADDR + ); + if (senderPartner) { + expect(senderPartner.direction).toBe("received"); + } + }); +}); + +// ---- D1-throws fallback with new parallel queries --------------------------- + +describe("Phase 2.5 Step 3.3 — D1-throws fallback still works with added parallel queries", () => { + it("returns 503 when countOutboxRepliesFromD1 throws", async () => { + (listInboxMessagesFromD1 as Mock).mockResolvedValue([]); + (countInboxMessagesFromD1 as Mock).mockResolvedValue(0); + (countOutboxRepliesFromD1 as Mock).mockRejectedValue( + new Error("D1_ERROR: outbox table unavailable") + ); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([]); + + const res = await GET(buildGetRequest(AGENT_ADDR), buildContext(AGENT_ADDR)); + + expect(res.status).toBe(503); + expect(res.status).not.toBe(500); + const body = await res.json(); + expect(body.error).toBe("transient_d1_unavailable"); + expect(res.headers.get("Retry-After")).toBe("5"); + }); + + it("returns 503 when listOutboxRepliesFromD1 throws (partners requested)", async () => { + (listInboxMessagesFromD1 as Mock).mockResolvedValue([]); + (countInboxMessagesFromD1 as Mock).mockResolvedValue(0); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(0); + (listOutboxRepliesFromD1 as Mock).mockRejectedValue( + new Error("D1_ERROR: schema mismatch") + ); + + const res = await GET( + buildGetRequest(AGENT_ADDR, "?include=partners"), + buildContext(AGENT_ADDR) + ); + + expect(res.status).toBe(503); + const body = await res.json(); + expect(body.error).toBe("transient_d1_unavailable"); + }); +}); diff --git a/app/api/inbox/[address]/route.ts b/app/api/inbox/[address]/route.ts index 2ce83edb..1359df0e 100644 --- a/app/api/inbox/[address]/route.ts +++ b/app/api/inbox/[address]/route.ts @@ -37,6 +37,8 @@ import { listInboxMessagesFromD1, countInboxMessagesFromD1, fetchRepliesForMessages, + listOutboxRepliesFromD1, + countOutboxRepliesFromD1, type StatusFilter, } from "@/lib/inbox/d1-reads"; @@ -252,14 +254,18 @@ export async function GET( let unreadCount: number; let totalCount: number; let receivedCount: number; + let sentCount: number; + let sentMessages: import("@/lib/inbox/types").OutboxReply[]; try { // D1 query: received messages with status filter and pagination. - // All four queries run in parallel to minimise latency: + // All six queries run in parallel to minimise latency: // [0] paginated message list (the page the caller asked for) // [1] unreadCount — live SELECT COUNT(*) WHERE read_at IS NULL (closes aibtc-mcp-server#497) // [2] totalCount — total matching the status filter (drives hasMore / pagination) // [3] receivedCount — total inbound messages regardless of filter (for economics) - [receivedMessages, unreadCount, totalCount, receivedCount] = await Promise.all([ + // [4] sentCount — total outbox replies sent by this agent (Phase 2.5 Step 3.3) + // [5] sentMessages — outbox replies for partner graph (only when includePartners) + [receivedMessages, unreadCount, totalCount, receivedCount, sentCount, sentMessages] = await Promise.all([ includeReceived ? listInboxMessagesFromD1(db, agent.btcAddress, limit, offset, statusFilter) : Promise.resolve([] as import("@/lib/inbox/types").InboxMessage[]), @@ -275,6 +281,12 @@ export async function GET( includeReceived ? countInboxMessagesFromD1(db, agent.btcAddress, "all") : Promise.resolve(0), + // sentCount: total outbox replies sent — restores the dimension stubbed in Step 3.1 + countOutboxRepliesFromD1(db, agent.btcAddress), + // sentMessages: outbox replies for partner graph computation (only when includePartners) + includePartners + ? listOutboxRepliesFromD1(db, agent.btcAddress, 100, 0) + : Promise.resolve([] as import("@/lib/inbox/types").OutboxReply[]), ]); } catch (e) { return NextResponse.json( @@ -287,8 +299,6 @@ export async function GET( ); } - const sentCount = 0; // Step 3.3: outbox flip not yet done - // Build inline replies map for the returned page const visibleMessageIds = receivedMessages.map((m) => m.messageId); const repliesMap = await fetchRepliesForMessages(db, visibleMessageIds); @@ -330,10 +340,11 @@ export async function GET( }); // Compute partner summary if requested. - // For now partners are computed from the received messages only (Step 3.3 will add sent). + // Step 3.3 restores the full partner graph: both received (inbound senders) + // and sent (outbox reply targets) directions are merged into the partner map. let partners: import("@/lib/inbox/types").InboxPartner[] | undefined; - if (includePartners && totalCount > 0) { - // Group received messages by partner (sender) address + if (includePartners && (totalCount > 0 || sentMessages.length > 0)) { + // Group messages by partner address — covers both directions const partnerMap = new Map; }>(); + // Received messages: partner is the sender (fromAddress = STX address) for (const msg of receivedMessages) { - // For received messages, partner is the sender (fromAddress = STX address) const partnerStxAddress = msg.fromAddress; const partnerBtcAddress = agentLookupMap.get(partnerStxAddress)?.btcAddress; const partnerKey = partnerBtcAddress || partnerStxAddress; @@ -366,6 +377,31 @@ export async function GET( } } + // Sent messages (outbox replies): partner is the reply target (toBtcAddress) + // This is always a BTC address (resolved at write time by insertReplyToD1). + for (const reply of sentMessages) { + const partnerBtcAddress = reply.toBtcAddress; + if (!partnerBtcAddress) continue; + const partnerKey = partnerBtcAddress; + + const existing = partnerMap.get(partnerKey); + if (existing) { + existing.messageCount++; + existing.directions.add("sent"); + if (new Date(reply.repliedAt).getTime() > new Date(existing.lastInteractionAt).getTime()) { + existing.lastInteractionAt = reply.repliedAt; + } + } else { + partnerMap.set(partnerKey, { + btcAddress: partnerBtcAddress, + stxAddress: undefined, + messageCount: 1, + lastInteractionAt: reply.repliedAt, + directions: new Set(["sent"]), + }); + } + } + // Resolve partner addresses to agent records for display names const partnerEntries = Array.from(partnerMap.entries()); const resolvedPartners = await Promise.all( @@ -373,7 +409,7 @@ export async function GET( const lookupAddress = data.stxAddress || data.btcAddress; const partnerAgent = lookupAddress ? await lookupAgent(kv, lookupAddress) : null; - // Determine final direction — all received-only for now (Step 3.3 adds sent) + // Determine final direction from the merged set let finalDirection: "sent" | "received" | "both"; if (data.directions.has("sent") && data.directions.has("received")) { finalDirection = "both"; @@ -428,8 +464,11 @@ export async function GET( partners = dedupedPartners.slice(0, 10); } - // If no messages, return self-documenting response - if (totalCount === 0) { + // If the agent has truly never had any inbox activity (no received messages + // AND no sent replies), return the self-documenting response. An agent that + // has only sent replies (sentCount > 0, totalCount === 0) falls through to + // the normal envelope so sentCount/economics/partners are exposed. + if (totalCount === 0 && sentCount === 0) { return NextResponse.json({ endpoint: "/api/inbox/[address]", description: diff --git a/app/api/outbox/[address]/__tests__/d1-reads-flip.test.ts b/app/api/outbox/[address]/__tests__/d1-reads-flip.test.ts new file mode 100644 index 00000000..1db02bb4 --- /dev/null +++ b/app/api/outbox/[address]/__tests__/d1-reads-flip.test.ts @@ -0,0 +1,425 @@ +/** + * Phase 2.5 Step 3.3 — GET /api/outbox/[address] D1 read flip. + * + * Scope: outbox-route assertions only. The inbox-list sentCount restoration + * and partners-with-sent coverage live in + * `app/api/inbox/[address]/__tests__/d1-sentcount-partners.test.ts`. + * + * Covers: + * 1. 200 — replies exist, returned correctly from D1 + * 2. Empty outbox — self-documenting response only when totalCount === 0 + * 3. 404 — agent not found + * 4. Tenant-discriminator security gate: reply written by addr_A MUST NOT appear + * in GET /api/outbox/addr_B — SQL WHERE from_btc_address=? enforces this. + * The route returns empty (not a leaked reply) when address doesn't match. + * 5. 503 — D1 throws → structured fallback (not unhandled 500) + * 6. Pagination metadata: totalCount comes from COUNT(*), not page length; + * hasMore/nextOffset derived from offset + replies.length < totalCount + * 7. Out-of-range offset returns normal envelope with empty replies, not self-doc + * 8. NaN guard: non-numeric ?limit / ?offset returns 400, not 503 + * + * See: https://github.com/aibtcdev/landing-page/issues/728 (Step 3.3 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", () => ({ + listOutboxRepliesFromD1: vi.fn(), + countOutboxRepliesFromD1: vi.fn(), +})); + +vi.mock("@/lib/inbox", () => ({ + validateOutboxReply: vi.fn(), + getMessage: vi.fn(), + getReply: vi.fn(), + storeReply: vi.fn(), + updateMessage: vi.fn(), + buildReplyMessage: vi.fn(() => "Inbox Reply | msg_test | reply"), + decrementUnreadCount: vi.fn(), +})); + +vi.mock("@/lib/inbox/d1-dual-write", () => ({ + insertReplyToD1: vi.fn().mockResolvedValue(undefined), + 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(), debug: vi.fn() }), + createConsoleLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), + isLogsRPC: () => false, +})); + +vi.mock("@/lib/env", () => ({ + shouldFailClosed: vi.fn(() => false), +})); + +vi.mock("@/lib/validation/address", () => ({ + isStxAddress: vi.fn(() => false), +})); + +// ---- imports after mocks ---------------------------------------------------- + +import { GET } from "../route"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { lookupAgent } from "@/lib/agent-lookup"; +import { listOutboxRepliesFromD1, countOutboxRepliesFromD1 } from "@/lib/inbox/d1-reads"; + +// ---- shared fixtures -------------------------------------------------------- + +const ADDR_A = "bc1qxj5jtv8jwm7zv2nczn2xfq9agjgj0sqpsxn43h"; +const ADDR_B = "bc1qw0y4ant38zykzjqssgnujqmszruvhkwupvp6dn"; + +const AGENT_A = { + btcAddress: ADDR_A, + stxAddress: "SP3JR7JXFT7ZM9JKSQPBQG1HPT0D365MA5TN0P12E", + displayName: "Frosty Narwhal", +}; + +const AGENT_B = { + btcAddress: ADDR_B, + stxAddress: "SP3GXCKM4AB5EB1KJ8V5QSTR1XMTW3R142VQS2NVW", + displayName: "Amber Otter", +}; + +const REPLY_FROM_A = { + messageId: "msg_1778221238475_parent", + fromAddress: ADDR_A, + toBtcAddress: ADDR_B, + reply: "Thanks for the message!", + signature: "sig_abc123base64", + 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): NextRequest { + return new NextRequest( + `https://aibtc.com/api/outbox/${address}`, + { method: "GET" } + ); +} + +function buildContext(address: string) { + return { params: Promise.resolve({ address }) }; +} + +beforeEach(() => { + vi.clearAllMocks(); + + (getCloudflareContext as Mock).mockReturnValue({ + env: { + DB: makeMockDB(), + VERIFIED_AGENTS: {} as KVNamespace, + }, + ctx: { waitUntil: vi.fn() }, + }); +}); + +// ---- tests ------------------------------------------------------------------ + +function buildGetRequestWithQuery(address: string, query: string): NextRequest { + return new NextRequest( + `https://aibtc.com/api/outbox/${address}${query}`, + { method: "GET" } + ); +} + +describe("Phase 2.5 Step 3.3 — GET /api/outbox/[address] D1 flip", () => { + it("returns 200 with outbox shape when replies exist in D1", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([REPLY_FROM_A]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(1); + + const res = await GET(buildGetRequest(ADDR_A), buildContext(ADDR_A)); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.outbox).toBeDefined(); + expect(body.outbox.replies).toHaveLength(1); + expect(body.outbox.replies[0]).toMatchObject({ + fromAddress: ADDR_A, + toBtcAddress: ADDR_B, + reply: "Thanks for the message!", + }); + expect(body.outbox.totalCount).toBe(1); + expect(body.agent.btcAddress).toBe(ADDR_A); + }); + + it("returns self-documenting empty response when totalCount === 0", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(0); + + const res = await GET(buildGetRequest(ADDR_A), buildContext(ADDR_A)); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.outbox.replies).toHaveLength(0); + expect(body.outbox.totalCount).toBe(0); + // Self-documenting — includes howToReply + expect(body.howToReply).toBeDefined(); + expect(body.endpoint).toBe("/api/outbox/[address]"); + }); + + it("returns 404 when agent not found", async () => { + (lookupAgent as Mock).mockResolvedValue(null); + + const res = await GET(buildGetRequest(ADDR_A), buildContext(ADDR_A)); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("Agent not found"); + }); + + it("tenant-discriminator security gate: GET /api/outbox/addr_B does NOT return addr_A's reply", async () => { + // BLOCK-ON-MERGE per #728 Step 3.3 spec (analog of #725 address-match guard). + // The SQL WHERE from_btc_address = ? ensures addr_B's query never matches addr_A's rows. + // When addr_B has no sent replies, D1 returns empty — not a leaked reply from addr_A. + (lookupAgent as Mock).mockResolvedValue(AGENT_B); + // D1 returns empty because ADDR_B has not sent any replies + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(0); + + const res = await GET(buildGetRequest(ADDR_B), buildContext(ADDR_B)); + + expect(res.status).toBe(200); + const body = await res.json(); + // MUST be empty — addr_A's reply must NOT appear here + expect(body.outbox.replies).toHaveLength(0); + expect(body.outbox.totalCount).toBe(0); + + // Verify both D1 queries were called with ADDR_B (not ADDR_A) + expect(listOutboxRepliesFromD1).toHaveBeenCalledOnce(); + const [, listCalledAddress] = (listOutboxRepliesFromD1 as Mock).mock.calls[0]; + expect(listCalledAddress).toBe(ADDR_B); + expect(countOutboxRepliesFromD1).toHaveBeenCalledOnce(); + const [, countCalledAddress] = (countOutboxRepliesFromD1 as Mock).mock.calls[0]; + expect(countCalledAddress).toBe(ADDR_B); + }); + + it("returns 503 with structured body when listOutboxRepliesFromD1 throws — not unhandled 500", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + (listOutboxRepliesFromD1 as Mock).mockRejectedValue( + new Error("D1_ERROR: connection reset") + ); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(1); + + const res = await GET(buildGetRequest(ADDR_A), buildContext(ADDR_A)); + + 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 with structured body when countOutboxRepliesFromD1 throws", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([REPLY_FROM_A]); + (countOutboxRepliesFromD1 as Mock).mockRejectedValue( + new Error("D1_ERROR: count query failed") + ); + + const res = await GET(buildGetRequest(ADDR_A), buildContext(ADDR_A)); + + expect(res.status).toBe(503); + const body = await res.json(); + expect(body.error).toBe("transient_d1_unavailable"); + }); + + it("includes pagination shape in response", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([REPLY_FROM_A]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(1); + + const res = await GET(buildGetRequest(ADDR_A), buildContext(ADDR_A)); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.outbox.pagination).toBeDefined(); + expect(body.outbox.pagination).toMatchObject({ + limit: 20, + offset: 0, + }); + }); +}); + +describe("Phase 2.5 Step 3.3 — pagination metadata correctness", () => { + it("totalCount reflects COUNT(*) result, not the current page length", async () => { + // Agent has 50 lifetime replies but the page returns only 20 (default limit). + // totalCount must be 50, not 20. + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + const pageReplies = Array.from({ length: 20 }, (_, i) => ({ + ...REPLY_FROM_A, + messageId: `msg_${i}_parent`, + })); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue(pageReplies); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(50); + + const res = await GET(buildGetRequest(ADDR_A), buildContext(ADDR_A)); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.outbox.totalCount).toBe(50); + expect(body.outbox.replies).toHaveLength(20); + expect(body.outbox.pagination.hasMore).toBe(true); + expect(body.outbox.pagination.nextOffset).toBe(20); + }); + + it("hasMore is false on the final full page (replies.length === limit but no remaining rows)", async () => { + // Edge case: page exactly fills (limit=20) but there are no more rows. + // Pre-fix this reported hasMore: true incorrectly. + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + const pageReplies = Array.from({ length: 20 }, (_, i) => ({ + ...REPLY_FROM_A, + messageId: `msg_${i}_parent`, + })); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue(pageReplies); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(20); + + const res = await GET(buildGetRequest(ADDR_A), buildContext(ADDR_A)); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.outbox.pagination.hasMore).toBe(false); + expect(body.outbox.pagination.nextOffset).toBeNull(); + }); + + it("out-of-range offset returns normal envelope with empty replies, not self-doc", async () => { + // Agent has 5 replies; caller requests offset=100. D1 returns empty page, + // but totalCount=5 means the agent does have history. Response should be + // the normal envelope with accurate pagination, NOT the self-doc. + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(5); + + const res = await GET( + buildGetRequestWithQuery(ADDR_A, "?offset=100"), + buildContext(ADDR_A) + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.outbox.replies).toHaveLength(0); + expect(body.outbox.totalCount).toBe(5); + expect(body.outbox.pagination.offset).toBe(100); + expect(body.outbox.pagination.hasMore).toBe(false); + // Must NOT be the self-doc shape + expect(body.howToReply).toBeUndefined(); + expect(body.endpoint).toBeUndefined(); + }); +}); + +describe("Phase 2.5 Step 3.3 — query param validation (NaN guard)", () => { + it("rejects non-numeric ?limit with 400, not 503", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + + const res = await GET( + buildGetRequestWithQuery(ADDR_A, "?limit=abc"), + buildContext(ADDR_A) + ); + + expect(res.status).toBe(400); + expect(res.status).not.toBe(503); + const body = await res.json(); + expect(body.error).toBe("invalid_query_param"); + // D1 helpers must never be invoked for invalid input + expect(listOutboxRepliesFromD1).not.toHaveBeenCalled(); + expect(countOutboxRepliesFromD1).not.toHaveBeenCalled(); + }); + + it("rejects out-of-range ?limit=0 with 400", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + + const res = await GET( + buildGetRequestWithQuery(ADDR_A, "?limit=0"), + buildContext(ADDR_A) + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("invalid_query_param"); + }); + + it("rejects ?limit=101 with 400 (max 100)", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + + const res = await GET( + buildGetRequestWithQuery(ADDR_A, "?limit=101"), + buildContext(ADDR_A) + ); + + expect(res.status).toBe(400); + }); + + it("rejects negative ?offset with 400", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + + const res = await GET( + buildGetRequestWithQuery(ADDR_A, "?offset=-1"), + buildContext(ADDR_A) + ); + + expect(res.status).toBe(400); + }); + + it("rejects non-integer ?limit=1.5 with 400", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + + const res = await GET( + buildGetRequestWithQuery(ADDR_A, "?limit=1.5"), + buildContext(ADDR_A) + ); + + expect(res.status).toBe(400); + }); + + it("accepts ?limit=100 and ?offset=0 (boundary)", async () => { + (lookupAgent as Mock).mockResolvedValue(AGENT_A); + (listOutboxRepliesFromD1 as Mock).mockResolvedValue([REPLY_FROM_A]); + (countOutboxRepliesFromD1 as Mock).mockResolvedValue(1); + + const res = await GET( + buildGetRequestWithQuery(ADDR_A, "?limit=100&offset=0"), + buildContext(ADDR_A) + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.outbox.pagination.limit).toBe(100); + expect(body.outbox.pagination.offset).toBe(0); + }); +}); diff --git a/app/api/outbox/[address]/route.ts b/app/api/outbox/[address]/route.ts index fb91013b..cb72f880 100644 --- a/app/api/outbox/[address]/route.ts +++ b/app/api/outbox/[address]/route.ts @@ -14,12 +14,12 @@ import { storeReply, updateMessage, buildReplyMessage, - listInboxMessages, decrementUnreadCount, } from "@/lib/inbox"; import { isStxAddress } from "@/lib/validation/address"; import { shouldFailClosed } from "@/lib/env"; import { insertReplyToD1, updateMessageStateD1 } from "@/lib/inbox/d1-dual-write"; +import { listOutboxRepliesFromD1, countOutboxRepliesFromD1 } from "@/lib/inbox/d1-reads"; /** Retry-After value (seconds) to return on 429s — matches the 60s binding window. */ const RATE_LIMIT_RETRY_AFTER = 60; @@ -513,20 +513,83 @@ export async function GET( ); } - // Fetch all messages with replies inline (single call, no N+1) - const { replies: replyMap } = await listInboxMessages( - kv, - agent.btcAddress, - 100, - 0, - { includeReplies: true } - ); + // ── Phase 2.5 Step 3.3: D1 read flip ────────────────────────────────────── + // The GET /api/outbox/[address] path now reads from D1 instead of KV. + // KV writes (POST handler) are NOT removed in this PR — that is Step 4. + // Security gate: listOutboxRepliesFromD1 filters by from_btc_address = ? + // so replies belonging to a different agent are never returned. + // + // See: https://github.com/aibtcdev/landing-page/issues/728 (Step 3.3 spec) + // See: https://github.com/aibtcdev/landing-page/issues/697 (Phase 2.5 umbrella) + + // Parse query params for pagination. + // Validate before binding to D1: non-numeric inputs (e.g. ?limit=abc) must + // produce 400, not 503 from a downstream D1 NaN bind throw. + const url = new URL(request.url); + const limitParam = url.searchParams.get("limit"); + const offsetParam = url.searchParams.get("offset"); + + let limit = 20; + if (limitParam !== null) { + const parsed = Number(limitParam); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1 || parsed > 100) { + return NextResponse.json( + { error: "invalid_query_param", message: "limit must be an integer between 1 and 100" }, + { status: 400 } + ); + } + limit = parsed; + } + + let offset = 0; + if (offsetParam !== null) { + const parsed = Number(offsetParam); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) { + return NextResponse.json( + { error: "invalid_query_param", message: "offset must be a non-negative integer" }, + { status: 400 } + ); + } + offset = parsed; + } + + const db = env.DB as D1Database | undefined; + + if (!db) { + return NextResponse.json( + { error: "Database unavailable. Please try again shortly." }, + { status: 503 } + ); + } - // Collect all replies - const validReplies = Array.from(replyMap.values()); + // D1-throws fallback policy (per #728 / #722 dev-council Cycle 26 advisory): + // If D1 throws — transient unavailability, network error, schema mismatch — + // return 503 with a structured retry hint rather than an unstructured 500. + // totalCount is queried in parallel with the page list so pagination metadata + // reflects the full matching row count, not just the current page length. + let replies: import("@/lib/inbox/types").OutboxReply[]; + let totalCount: number; + try { + [replies, totalCount] = await Promise.all([ + listOutboxRepliesFromD1(db, agent.btcAddress, limit, offset), + countOutboxRepliesFromD1(db, agent.btcAddress), + ]); + } catch (e) { + return NextResponse.json( + { + error: "transient_d1_unavailable", + message: "Outbox database temporarily unavailable. Please retry shortly.", + retry_after: 5, + }, + { status: 503, headers: { "Retry-After": "5" } } + ); + } - // If no replies, return self-documenting response - if (validReplies.length === 0) { + // Self-documenting response only when the agent has truly never sent a + // reply (totalCount === 0). Out-of-range pages (offset > 0 but the agent + // does have history) get the normal envelope with empty `replies` and + // accurate pagination so clients can recover. + if (totalCount === 0) { return NextResponse.json({ endpoint: "/api/outbox/[address]", description: "Replies sent by this agent to incoming inbox messages.", @@ -537,6 +600,12 @@ export async function GET( outbox: { replies: [], totalCount: 0, + pagination: { + limit, + offset, + hasMore: false, + nextOffset: null, + }, }, howToReply: { endpoint: `POST /api/outbox/${agent.btcAddress}`, @@ -555,14 +624,21 @@ export async function GET( }); } + const hasMore = offset + replies.length < totalCount; return NextResponse.json({ agent: { btcAddress: agent.btcAddress, displayName: agent.displayName, }, outbox: { - replies: validReplies, - totalCount: validReplies.length, + replies, + totalCount, + pagination: { + limit, + offset, + hasMore, + nextOffset: hasMore ? offset + replies.length : null, + }, }, }); } diff --git a/lib/inbox/__tests__/d1-reads.test.ts b/lib/inbox/__tests__/d1-reads.test.ts index 86575db3..6d707ec7 100644 --- a/lib/inbox/__tests__/d1-reads.test.ts +++ b/lib/inbox/__tests__/d1-reads.test.ts @@ -25,6 +25,8 @@ import { listInboxMessagesFromD1, countInboxMessagesFromD1, fetchRepliesForMessages, + listOutboxRepliesFromD1, + countOutboxRepliesFromD1, type StatusFilter, } from "../d1-reads"; @@ -462,6 +464,156 @@ describe("fetchRepliesForMessages", () => { }); }); +// ── listOutboxRepliesFromD1 ─────────────────────────────────────────────────── + +describe("listOutboxRepliesFromD1 (Phase 2.5 Step 3.3)", () => { + const REPLIER_BTC = "bc1qp66jvxe765wgwpzqk8kcrmgh2mucyxg540mtzv"; + const SENT_REPLY_ROW = { + message_id: "reply_msg_1771381602504_test", + reply_to_message_id: "msg_1771381602504_test", + from_btc_address: REPLIER_BTC, + to_btc_address: BTC_ADDRESS, + content: "Great work — bookmarked.", + bitcoin_signature: + "Jx52I99dmnoFqmKkJXsLP4ELktANgZ6v1m1CFA7c5kz+Xr9W45m29QnabzGim5ubEzJP1eoynU/GjuRWMjRD9nQ=", + sent_at: "2026-02-19T22:14:43.426Z", + }; + + it("queries WHERE is_reply=1 AND from_btc_address=? with ORDER BY sent_at DESC", async () => { + const db = createMockD1(); + const stmtMock = createPreparedStatement([]); + (db.prepare as ReturnType).mockReturnValue(stmtMock); + + await listOutboxRepliesFromD1(db, REPLIER_BTC, 20, 0); + + const sql: string = (db.prepare as ReturnType).mock.calls[0][0]; + expect(sql).toContain("FROM inbox_messages"); + expect(sql).toContain("WHERE is_reply = 1 AND from_btc_address = ?"); + expect(sql).toContain("ORDER BY sent_at DESC"); + expect(sql).toContain("LIMIT ? OFFSET ?"); + }); + + it("binds from_btc_address, limit, offset in order", async () => { + const db = createMockD1(); + const stmtMock = createPreparedStatement([]); + (db.prepare as ReturnType).mockReturnValue(stmtMock); + + await listOutboxRepliesFromD1(db, REPLIER_BTC, 50, 10); + + const bindArgs: unknown[] = stmtMock.bind.mock.calls[0]; + expect(bindArgs[0]).toBe(REPLIER_BTC); + expect(bindArgs[1]).toBe(50); // limit + expect(bindArgs[2]).toBe(10); // offset + }); + + it("maps D1 reply row to OutboxReply shape", async () => { + const stmtMock = createPreparedStatement([SENT_REPLY_ROW]); + const db = { + prepare: vi.fn().mockReturnValue(stmtMock), + batch: vi.fn(), dump: vi.fn(), exec: vi.fn(), + } as unknown as D1Database; + + const results = await listOutboxRepliesFromD1(db, REPLIER_BTC, 20, 0); + + expect(results).toHaveLength(1); + const reply = results[0]; + // replyRowToOutboxReply: messageId = reply_to_message_id (parent ID) + expect(reply.messageId).toBe(SENT_REPLY_ROW.reply_to_message_id); + expect(reply.fromAddress).toBe(SENT_REPLY_ROW.from_btc_address); + expect(reply.toBtcAddress).toBe(SENT_REPLY_ROW.to_btc_address); + expect(reply.reply).toBe(SENT_REPLY_ROW.content); + expect(reply.signature).toBe(SENT_REPLY_ROW.bitcoin_signature); + expect(reply.repliedAt).toBe(SENT_REPLY_ROW.sent_at); + }); + + it("returns empty array when no rows", async () => { + const db = createMockD1([]); + const result = await listOutboxRepliesFromD1(db, REPLIER_BTC, 20, 0); + expect(result).toHaveLength(0); + }); + + it("tenant-discriminator security gate: SQL WHERE from_btc_address=? enforces address isolation", async () => { + // This test documents the security property: a reply written by REPLIER_BTC + // will NOT be returned when the query uses a different address. + // The SQL gate (WHERE is_reply = 1 AND from_btc_address = ?) enforces this at the DB level. + const db = createMockD1(); + const stmtMock = createPreparedStatement([]); // D1 returns empty for non-matching address + (db.prepare as ReturnType).mockReturnValue(stmtMock); + + // Query with ADDR_B (not the replier) + const ADDR_B = "bc1qw0y4ant38zykzjqssgnujqmszruvhkwupvp6dn"; + const results = await listOutboxRepliesFromD1(db, ADDR_B, 100, 0); + + // Must be empty — the SQL WHERE clause prevents cross-agent leakage + expect(results).toHaveLength(0); + + // Verify the query was called with ADDR_B — the guard happens in SQL + const bindArgs: unknown[] = stmtMock.bind.mock.calls[0]; + expect(bindArgs[0]).toBe(ADDR_B); + }); +}); + +// ── countOutboxRepliesFromD1 ────────────────────────────────────────────────── + +describe("countOutboxRepliesFromD1 (Phase 2.5 Step 3.3 — sentCount restoration)", () => { + const REPLIER_BTC = "bc1qp66jvxe765wgwpzqk8kcrmgh2mucyxg540mtzv"; + + it("queries SELECT COUNT(*) WHERE is_reply=1 AND from_btc_address=?", async () => { + const stmtMock = createPreparedStatement([], { cnt: 3 }); + const db = { + prepare: vi.fn().mockReturnValue(stmtMock), + batch: vi.fn(), dump: vi.fn(), exec: vi.fn(), + } as unknown as D1Database; + + const count = await countOutboxRepliesFromD1(db, REPLIER_BTC); + + expect(count).toBe(3); + const sql: string = (db.prepare as ReturnType).mock.calls[0][0]; + expect(sql).toContain("SELECT COUNT(*)"); + expect(sql).toContain("FROM inbox_messages"); + expect(sql).toContain("is_reply = 1"); + expect(sql).toContain("from_btc_address = ?"); + }); + + it("returns 0 when db.first() returns null", async () => { + const stmtMock = createPreparedStatement([], null); + const db = { + prepare: vi.fn().mockReturnValue(stmtMock), + batch: vi.fn(), dump: vi.fn(), exec: vi.fn(), + } as unknown as D1Database; + + const count = await countOutboxRepliesFromD1(db, REPLIER_BTC); + expect(count).toBe(0); + }); + + it("sentCount restoration: returns > 0 for known-replier address (Step 3.3 acceptance signal)", async () => { + // This test represents the acceptance signal for Step 3.3: + // POST-flip, countOutboxRepliesFromD1 must return > 0 for an address that has sent replies. + // Was stubbed to 0 in Step 3.1 ("const sentCount = 0;"). + const stmtMock = createPreparedStatement([], { cnt: 5 }); + const db = { + prepare: vi.fn().mockReturnValue(stmtMock), + batch: vi.fn(), dump: vi.fn(), exec: vi.fn(), + } as unknown as D1Database; + + const count = await countOutboxRepliesFromD1(db, REPLIER_BTC); + expect(count).toBeGreaterThan(0); // Step 3.3 acceptance signal: sentCount > 0 + }); + + it("binds from_btc_address as the first positional param", async () => { + const stmtMock = createPreparedStatement([], { cnt: 0 }); + const db = { + prepare: vi.fn().mockReturnValue(stmtMock), + batch: vi.fn(), dump: vi.fn(), exec: vi.fn(), + } as unknown as D1Database; + + await countOutboxRepliesFromD1(db, REPLIER_BTC); + + const bindArgs: unknown[] = stmtMock.bind.mock.calls[0]; + expect(bindArgs[0]).toBe(REPLIER_BTC); + }); +}); + // ── Cache-key invariant: structural verification ────────────────────────────── describe("cache-key invariants (structural verification)", () => { @@ -476,6 +628,8 @@ describe("cache-key invariants (structural verification)", () => { expect(listInboxMessagesFromD1.length).toBe(5); // (db, btcAddress, limit, offset, status) expect(countInboxMessagesFromD1.length).toBe(3); // (db, btcAddress, status) expect(fetchRepliesForMessages.length).toBe(2); // (db, parentMessageIds) + expect(listOutboxRepliesFromD1.length).toBe(4); // (db, btcAddress, limit, offset) + expect(countOutboxRepliesFromD1.length).toBe(2); // (db, btcAddress) }); it("Invariant 3: read helpers are called with explicit inputs — no implicit cache-before-auth", async () => { diff --git a/lib/inbox/d1-reads.ts b/lib/inbox/d1-reads.ts index 3c32a549..08f84b61 100644 --- a/lib/inbox/d1-reads.ts +++ b/lib/inbox/d1-reads.ts @@ -3,6 +3,8 @@ * * 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. + * Phase 2.5 Step 3.3 — adds listOutboxRepliesFromD1 + countOutboxRepliesFromD1 + * for the outbox GET flip and sentCount/partners restoration in inbox-list. * KV writes are NOT removed here (that is Step 4). * * These helpers query the inbox_messages table that is being populated by @@ -21,6 +23,7 @@ * * 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/728 (Step 3.3 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) */ @@ -59,7 +62,6 @@ interface D1InboxRow { * A single reply row (is_reply=1) joined or fetched for inline enrichment. */ interface D1ReplyRow { - message_id: string; reply_to_message_id: string; from_btc_address: string | null; to_btc_address: string; @@ -204,7 +206,7 @@ export async function fetchRepliesForMessages( const placeholders = parentMessageIds.map(() => "?").join(", "); const sql = ` SELECT - message_id, reply_to_message_id, from_btc_address, to_btc_address, + reply_to_message_id, from_btc_address, to_btc_address, content, bitcoin_signature, sent_at FROM inbox_messages WHERE is_reply = 1 @@ -265,5 +267,73 @@ export async function getInboxMessageFromD1( return rowToInboxMessage(row); } +/** + * Fetch a page of outbox replies sent by an agent from D1. + * + * Security gate: SQL WHERE clause filters by `from_btc_address = ?` so replies + * belonging to a different address are never returned, even if the caller + * supplies a mismatched URL address. + * + * SQL shape (refs #728 Step 3.3 spec): + * SELECT … FROM inbox_messages + * WHERE is_reply = 1 AND from_btc_address = ? + * ORDER BY sent_at DESC + * LIMIT ? OFFSET ? + * + * Used by GET /api/outbox/[address] and by the sentCount/partners restoration + * in GET /api/inbox/[address]. + */ +export async function listOutboxRepliesFromD1( + db: D1Database, + btcAddress: string, + limit: number, + offset: number +): Promise { + const sql = ` + SELECT + reply_to_message_id, from_btc_address, to_btc_address, + content, bitcoin_signature, sent_at + FROM inbox_messages + WHERE is_reply = 1 AND from_btc_address = ? + ORDER BY sent_at DESC + LIMIT ? OFFSET ? + `; + + const result = await db + .prepare(sql) + .bind(btcAddress, limit, offset) + .all(); + + return (result.results ?? []).map(replyRowToOutboxReply); +} + +/** + * Count outbox replies sent by an agent from D1. + * + * Used to restore the `sentCount` dimension in GET /api/inbox/[address] that + * was stubbed to 0 in Step 3.1. + * + * SQL shape: + * SELECT COUNT(*) FROM inbox_messages + * WHERE is_reply = 1 AND from_btc_address = ? + */ +export async function countOutboxRepliesFromD1( + db: D1Database, + btcAddress: string +): Promise { + const sql = ` + SELECT COUNT(*) AS cnt + FROM inbox_messages + WHERE is_reply = 1 AND from_btc_address = ? + `; + + const row = await db + .prepare(sql) + .bind(btcAddress) + .first<{ cnt: number }>(); + + return row?.cnt ?? 0; +} + // Re-export the prefix so tests can verify synthesized IDs export { REPLY_D1_PK_PREFIX };