From 59230a2b03478dbd2254d776ae95d067a2414f0c Mon Sep 17 00:00:00 2001 From: Shantanav Date: Sun, 2 Aug 2026 23:43:41 +0530 Subject: [PATCH 1/6] fix: bound marketplace session refresh to avoid infinite recursion --- .../src/marketplace/lib/marketplace-api.js | 47 ++++++++++++------- .../marketplace/lib/marketplace-api.test.js | 2 +- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/apps/web/src/marketplace/lib/marketplace-api.js b/apps/web/src/marketplace/lib/marketplace-api.js index c9d7eba..a7fbe60 100644 --- a/apps/web/src/marketplace/lib/marketplace-api.js +++ b/apps/web/src/marketplace/lib/marketplace-api.js @@ -34,29 +34,44 @@ export function __resetMarketplaceSessionCacheForTests() { cachedSessionPromise = null; } -/** Lazily creates (and caches) the anonymous marketplace capture session. */ +async function fetchMarketplaceSession({ fetcher, baseUrl }) { + const response = await fetcher(`${baseUrl}/v1/marketplace/sessions`, { + method: "POST", + headers: { accept: "application/json" }, + }); + const body = await readJson(response); + raiseIfFailed(response, body); + return MarketplaceSessionSchema.parse(body); +} + +/** + * Lazily creates (and caches) the anonymous marketplace capture session. + * Refreshes once if the cached token is at/near expiry — bounded to a single + * retry (never recurses) so a server that always issues short-lived tokens + * can never cause an unbounded refresh loop; a token that is still near + * expiry after one refresh is simply used as-is. + */ async function getMarketplaceSession({ fetcher = fetch, baseUrl = apiBaseUrl(), } = {}) { if (!cachedSessionPromise) { - cachedSessionPromise = (async () => { - const response = await fetcher(`${baseUrl}/v1/marketplace/sessions`, { - method: "POST", - headers: { accept: "application/json" }, - }); - const body = await readJson(response); - raiseIfFailed(response, body); - return MarketplaceSessionSchema.parse(body); - })().catch((error) => { - cachedSessionPromise = null; - throw error; - }); + cachedSessionPromise = fetchMarketplaceSession({ fetcher, baseUrl }).catch( + (error) => { + cachedSessionPromise = null; + throw error; + }, + ); } - const session = await cachedSessionPromise; + let session = await cachedSessionPromise; if (Date.parse(session.expiresAt) <= Date.now() + 60_000) { - cachedSessionPromise = null; - return getMarketplaceSession({ fetcher, baseUrl }); + cachedSessionPromise = fetchMarketplaceSession({ fetcher, baseUrl }).catch( + (error) => { + cachedSessionPromise = null; + throw error; + }, + ); + session = await cachedSessionPromise; } return session; } diff --git a/apps/web/src/marketplace/lib/marketplace-api.test.js b/apps/web/src/marketplace/lib/marketplace-api.test.js index 000d6c3..61b961d 100644 --- a/apps/web/src/marketplace/lib/marketplace-api.test.js +++ b/apps/web/src/marketplace/lib/marketplace-api.test.js @@ -19,7 +19,7 @@ function withSessionBootstrap(handleCapture) { return new Response( JSON.stringify({ accessToken: "test-marketplace-session-token", - expiresAt: new Date(Date.now() + 60_000).toISOString(), + expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), }), { status: 200 }, ); From 2c5366cb10797dff5190e2c871d8c54951a4c512 Mon Sep 17 00:00:00 2001 From: Shantanav Date: Mon, 3 Aug 2026 00:14:29 +0530 Subject: [PATCH 2/6] fix: record topology decision so captured missions actually search --- .../marketplace-capture.integration.test.ts | 722 ++++++++++++++++++ apps/server/src/marketplace-capture.test.ts | 10 + apps/server/src/marketplace-capture.ts | 22 + 3 files changed, 754 insertions(+) create mode 100644 apps/server/src/marketplace-capture.integration.test.ts diff --git a/apps/server/src/marketplace-capture.integration.test.ts b/apps/server/src/marketplace-capture.integration.test.ts new file mode 100644 index 0000000..a71d8bc --- /dev/null +++ b/apps/server/src/marketplace-capture.integration.test.ts @@ -0,0 +1,722 @@ +import { randomUUID } from "node:crypto"; +import type { MarketplaceVisualFacts } from "@cascade/contracts"; +import { + applyMigrations, + MarketplaceCaptureStore, + PaymentAttemptStore, + PostgresCascadeKernel, +} from "@cascade/db"; +import { + listMarketplaceProducts, + MerchantAdapterRegistry, + MOCK_INDIA_ADAPTER_ID, + MockIndiaMerchantAdapter, +} from "@cascade/merchants"; +import { SensoClient } from "@cascade/senso"; +import { + FakeCheckoutCapabilityRegistry, + FakeClock, + FakeExternalRevocationExecutor, + type PostgresHarness, + SequentialUuidGenerator, + startPostgres, +} from "@cascade/testkit"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { + analyzeMarketplaceCapture, + type MarketplaceCaptureAnalysisDependencies, +} from "./marketplace-capture.js"; +import { createOfferWorkflowExecutor } from "./workflow.js"; + +const USER_ID = "80000000-0000-4000-8000-000000000001"; +const CHAT_ID = "80000000-0000-4000-8000-000000000002"; +const NOW = "2026-08-01T10:00:00.000Z"; + +let harness: PostgresHarness; +let clock: FakeClock; +let ids: SequentialUuidGenerator; + +function required(value: T | undefined, message: string): T { + if (value === undefined) throw new Error(message); + return value; +} + +// mock-india-marketplace.ts's listMarketplaceProducts() is a pure view over +// the same MOCK_INDIA_LISTINGS the mock-india merchant adapter searches, so +// the marketplace catalogue and mission search share one source of truth. +const PRODUCT = required( + listMarketplaceProducts()[0], + "Mock India catalog must have a product for tests", +); +const VARIANT = required( + PRODUCT.variants[0], + "Mock India product must have a default variant", +); + +function baseVisualFacts( + overrides: Partial = {}, +): MarketplaceVisualFacts { + return { + productType: "product", + confidence: 0.9, + visibleVariant: {}, + conflicts: [], + ambiguities: [], + ...overrides, + }; +} + +function marketplaceContext( + overrides: Partial<{ displayedPriceMinor: number }> = {}, +) { + return { + source: "cascade_marketplace" as const, + productId: PRODUCT.id, + vendorId: PRODUCT.vendorId, + marketplaceRoute: "/marketplace/p1", + visibleProduct: { + title: PRODUCT.title, + vendorName: PRODUCT.vendorName, + category: PRODUCT.category, + displayedPriceMinor: + overrides.displayedPriceMinor ?? PRODUCT.minimumPriceMinor, + currency: PRODUCT.currency, + visibleAttributes: {}, + }, + capturedAt: NOW, + }; +} + +function response(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + async json() { + return body; + }, + async text() { + return JSON.stringify(body); + }, + }; +} + +beforeAll(async () => { + harness = await startPostgres(); + await applyMigrations(harness.pool); +}, 120_000); + +afterAll(async () => { + await harness.stop(); +}); + +beforeEach(async () => { + await harness.pool.query("TRUNCATE users, chats CASCADE"); + await harness.pool.query( + "INSERT INTO users(id, default_currency) VALUES ($1, 'INR')", + [USER_ID], + ); + await harness.pool.query( + "INSERT INTO chats(id, provider, provider_chat_id, kind) VALUES ($1, 'test', $2, 'test')", + [CHAT_ID, randomUUID()], + ); + await harness.pool.query( + "INSERT INTO chat_memberships(chat_id, user_id) VALUES ($1, $2)", + [CHAT_ID, USER_ID], + ); + clock = new FakeClock(NOW); + ids = new SequentialUuidGenerator(); +}); + +/** + * Builds the real production wiring for a capture: a Postgres-backed kernel, + * capture store, and the exact same MockIndiaMerchantAdapter + offer workflow + * executor used by every other mission-creation path. `enqueueMissionSearch` + * runs the workflow executor inline instead of round-tripping through + * pg-boss, which is the same "mission.search" job body the real worker runs + * (see MissionSearchWorker) — only the queue hop is skipped so the test is + * deterministic. + */ +function buildHarness() { + const checkoutRegistry = new FakeCheckoutCapabilityRegistry( + new MockIndiaMerchantAdapter().supportedHosts.map((merchantHost) => ({ + merchantHost, + mode: "merchant_api" as const, + sandboxVerified: true, + })), + ); + const kernel = new PostgresCascadeKernel(harness.pool, { + clock, + uuid: ids, + checkoutRegistry, + externalRevocations: new FakeExternalRevocationExecutor(), + }); + const captures = new MarketplaceCaptureStore(harness.pool, { + clock, + uuid: ids, + }); + const merchants = new MerchantAdapterRegistry([ + new MockIndiaMerchantAdapter(), + ]); + const executor = createOfferWorkflowExecutor({ + pool: harness.pool, + kernel, + clock, + checkoutRegistry, + merchants, + senso: new SensoClient({ + apiKey: "unused-in-mock-workflow", + fetch: async () => response({ results: [] }), + }), + }); + const deps: MarketplaceCaptureAnalysisDependencies = { + pool: harness.pool, + jobs: { work: vi.fn() } as never, + captures, + storage: { + upload: vi.fn(async () => undefined), + download: vi.fn(async () => Buffer.from("fake-image")), + delete: vi.fn(async () => undefined), + } as never, + visualFacts: { extract: vi.fn(async () => baseVisualFacts()) }, + kernel, + clock, + uuid: ids, + enqueueMissionSearch: async (input) => { + await executor.execute(input); + }, + }; + return { kernel, captures, deps, checkoutRegistry }; +} + +async function createCapture( + captures: MarketplaceCaptureStore, + overrides: Partial[0]> = {}, +) { + return captures.create({ + principalId: USER_ID, + clientCaptureId: randomUUID(), + productId: PRODUCT.id, + vendorId: PRODUCT.vendorId, + variantId: VARIANT.id, + preset: "exact_product", + marketplaceContext: marketplaceContext(), + storageObjectPath: `captures/${USER_ID}/${randomUUID()}.png`, + screenshotSha256: "a".repeat(64), + screenshotMimeType: "image/png", + screenshotSizeBytes: 1024, + traceId: `trace-${randomUUID()}`, + retentionMs: 24 * 60 * 60_000, + ...overrides, + }); +} + +describe("marketplace capture -> mission.search -> mock vendor -> BUY (integration)", () => { + it("flows a verified capture through the shared mission pipeline: MockIndiaMerchantAdapter discovery, a real quote, and an eligible offer", async () => { + const { kernel, captures, deps } = buildHarness(); + const capture = await createCapture(captures); + + await analyzeMarketplaceCapture(capture.id, "trace-job-1", deps); + + const final = await captures.getById(capture.id); + expect(final?.status).toBe("mission_created"); + const missionId = final?.missionId; + if (!missionId) throw new Error("expected a mission id"); + + const view = await kernel.getMission({ missionId }); + expect(view.mission.state).toBe("awaiting_selection"); + expect(view.mission.ownerUserId).toBe(USER_ID); + expect(view.workflow.outcomeCode).toBe("OFFERS_AVAILABLE"); + + // Same registry the marketplace browse endpoint reads (listMarketplaceProducts) + // is what mission search discovered against: the offer is sourced from the + // mock-india adapter, not a capture-specific path, and its canonical key + // matches the trusted registry product/variant id compiled into the contract. + const offers = await harness.pool.query<{ + source_adapter: string; + eligible: boolean; + canonical_key: string; + }>( + `SELECT snapshot->>'sourceAdapter' AS source_adapter, eligible, + snapshot->'product'->>'canonicalKey' AS canonical_key + FROM offers WHERE mission_id = $1`, + [missionId], + ); + expect(offers.rows.length).toBeGreaterThan(0); + expect( + offers.rows.every( + ({ source_adapter }) => source_adapter === MOCK_INDIA_ADAPTER_ID, + ), + ).toBe(true); + expect(offers.rows.every(({ eligible }) => eligible)).toBe(true); + expect( + offers.rows.every( + ({ canonical_key }) => canonical_key === `${PRODUCT.id}:${VARIANT.id}`, + ), + ).toBe(true); + }); + + it("creates exactly one mission for a capture even under a retried analysis job (mission_id UNIQUE constraint + kernel command idempotency)", async () => { + const { captures, deps } = buildHarness(); + const capture = await createCapture(captures); + + await analyzeMarketplaceCapture(capture.id, "trace-job-1", deps); + // Simulate a retried pg-boss delivery of the same analysis job. + await analyzeMarketplaceCapture(capture.id, "trace-job-2", deps); + + const final = await captures.getById(capture.id); + const missionCount = await harness.pool.query<{ count: string }>( + "SELECT count(*) FROM missions WHERE owner_user_id = $1", + [USER_ID], + ); + expect(missionCount.rows[0]?.count).toBe("1"); + + // The unique constraint on marketplace_captures.mission_id is real, not + // just application-level: a second capture cannot be pointed at the same + // mission. + const other = await createCapture(captures, { + clientCaptureId: randomUUID(), + }); + await expect( + harness.pool.query( + `UPDATE marketplace_captures SET mission_id = $2 WHERE id = $1`, + [other.id, final?.missionId], + ), + ).rejects.toThrow(); + }); + + it("BUY requires an active quote: requestSpend fails before an offer is selected, and duplicate BUY does not create a second reservation/payment attempt", async () => { + const { kernel, captures, deps } = buildHarness(); + const capture = await createCapture(captures); + await analyzeMarketplaceCapture(capture.id, "trace-job-1", deps); + const missionId = (await captures.getById(capture.id))?.missionId; + if (!missionId) throw new Error("expected a mission id"); + + const view = await kernel.getMission({ missionId }); + const offerRow = await harness.pool.query<{ id: string }>( + "SELECT id FROM offers WHERE mission_id = $1 AND eligible = true LIMIT 1", + [missionId], + ); + const offerId = offerRow.rows[0]?.id; + if (!offerId || !view.contract) + throw new Error("expected an eligible offer"); + + const envelope = () => ({ + commandId: randomUUID(), + actorId: "user:test", + traceId: `trace-${randomUUID()}`, + issuedAt: NOW, + }); + + const capability = await kernel.issueCapability({ + ...envelope(), + missionId, + principalId: "checkout:test", + principalKind: "checkout", + allowedTools: ["checkout.execute"], + allowedMerchantHosts: [ + new URL( + ( + await harness.pool.query<{ url: string }>( + "SELECT snapshot->'merchant'->>'url' AS url FROM offers WHERE id = $1", + [offerId], + ) + ).rows[0]?.url ?? "https://example.com", + ).hostname, + ], + maximumAmountMinor: view.contract.spend.maximumTotalMinor, + currency: view.contract.spend.currency, + maximumExecutions: 1, + expiresAt: "2026-08-02T10:00:00.000Z", + }); + + // BUY before SELECT: no active quote is bound to the mission yet. + await expect( + kernel.requestSpend({ + ...envelope(), + missionId, + offerId, + capabilityLeaseId: capability.id, + principalId: capability.principalId, + expectedContractVersion: view.contract.version, + expectedContractHash: view.contract.contentHash, + }), + ).rejects.toMatchObject({ code: "UNSELECTED_OFFER" }); + + await kernel.selectOffer({ + ...envelope(), + missionId, + offerId, + expectedContractVersion: view.contract.version, + expectedContractHash: view.contract.contentHash, + }); + + const spendCommandId = randomUUID(); + const spendCommand = { + commandId: spendCommandId, + actorId: "user:test", + traceId: "trace-buy", + issuedAt: NOW, + missionId, + offerId, + capabilityLeaseId: capability.id, + principalId: capability.principalId, + expectedContractVersion: view.contract.version, + expectedContractHash: view.contract.contentHash, + }; + const first = await kernel.requestSpend(spendCommand); + // Duplicate BUY command (same idempotency key): returns the same + // reservation, never a second one. + const duplicate = await kernel.requestSpend(spendCommand); + expect(duplicate.id).toBe(first.id); + const reservations = await harness.pool.query<{ count: string }>( + "SELECT count(*) FROM reservations WHERE mission_id = $1", + [missionId], + ); + expect(reservations.rows[0]?.count).toBe("1"); + + // Prava sandbox authorizes the exact quote total, never the raw + // marketplace-displayed price. + const offerTotal = await harness.pool.query<{ total: string }>( + "SELECT snapshot->'price'->>'totalMinor' AS total FROM offers WHERE id = $1", + [offerId], + ); + expect(first.amountMinor).toBe(Number(offerTotal.rows[0]?.total)); + expect(first.amountMinor).not.toBe(PRODUCT.minimumPriceMinor); + + // Duplicate BUY with a *different* commandId is a second payment attempt + // by definition (a fresh idempotency key), but it must not create a + // second *reservation* while the first is still active. + const attempts = new PaymentAttemptStore({ + pool: harness.pool, + clock, + uuid: ids, + }); + const created = await attempts.beginCreate({ + missionId, + reservationId: first.id, + idempotencyKey: `payment:${randomUUID()}`, + actorId: "service:payment", + traceId: "trace-payment", + }); + expect(created.created).toBe(true); + // A second distinct payment attempt (fresh idempotency key) for the same + // reservation is rejected outright while the first is still live + // ('creating'/pending approval/etc.) — this is the guard that prevents a + // duplicate BUY from ever creating a second payment attempt. + await expect( + attempts.beginCreate({ + missionId, + reservationId: first.id, + idempotencyKey: `payment:${randomUUID()}`, + actorId: "service:payment", + traceId: "trace-payment", + }), + ).rejects.toMatchObject({ code: "PAYMENT_ATTEMPT_IN_PROGRESS" }); + const attemptCount = await harness.pool.query<{ count: string }>( + "SELECT count(*) FROM payment_attempts WHERE reservation_id = $1", + [first.id], + ); + expect(attemptCount.rows[0]?.count).toBe("1"); + }); + + it("duplicate checkout cannot create a second order (claimCheckout idempotency on a capture-sourced mission)", async () => { + const { kernel, captures, deps } = buildHarness(); + const capture = await createCapture(captures); + await analyzeMarketplaceCapture(capture.id, "trace-job-1", deps); + const missionId = (await captures.getById(capture.id))?.missionId; + if (!missionId) throw new Error("expected a mission id"); + const view = await kernel.getMission({ missionId }); + const offerRow = await harness.pool.query<{ id: string; url: string }>( + `SELECT id, snapshot->'merchant'->>'url' AS url FROM offers + WHERE mission_id = $1 AND eligible = true LIMIT 1`, + [missionId], + ); + const offerId = offerRow.rows[0]?.id; + const merchantUrl = offerRow.rows[0]?.url; + if (!offerId || !merchantUrl || !view.contract) { + throw new Error("expected an eligible offer"); + } + + const envelope = () => ({ + commandId: randomUUID(), + actorId: "user:test", + traceId: `trace-${randomUUID()}`, + issuedAt: NOW, + }); + const capability = await kernel.issueCapability({ + ...envelope(), + missionId, + principalId: "checkout:test", + principalKind: "checkout", + allowedTools: ["checkout.execute"], + allowedMerchantHosts: [new URL(merchantUrl).hostname], + maximumAmountMinor: view.contract.spend.maximumTotalMinor, + currency: view.contract.spend.currency, + maximumExecutions: 1, + expiresAt: "2026-08-02T10:00:00.000Z", + }); + await kernel.selectOffer({ + ...envelope(), + missionId, + offerId, + expectedContractVersion: view.contract.version, + expectedContractHash: view.contract.contentHash, + }); + const reservation = await kernel.requestSpend({ + ...envelope(), + missionId, + offerId, + capabilityLeaseId: capability.id, + principalId: capability.principalId, + expectedContractVersion: view.contract.version, + expectedContractHash: view.contract.contentHash, + }); + + const attempts = new PaymentAttemptStore({ + pool: harness.pool, + clock, + uuid: ids, + }); + const attempt = await attempts.create({ + missionId, + reservationId: reservation.id, + idempotencyKey: `payment:${randomUUID()}`, + actorId: "service:payment", + traceId: "trace-payment", + }); + await attempts.markSessionCreated({ + attemptId: attempt.id, + providerSessionId: `sess_${attempt.id}`, + providerOrderId: `order_${attempt.id}`, + expiresAt: "2026-08-01T10:15:00.000Z", + actorId: "service:payment", + traceId: "trace-payment", + }); + // Payment approval is not order completion: mission is payment_approved, + // order_state is still 'none' at this point. + await attempts.reconcileApproval({ + attemptId: attempt.id, + providerStatus: "awaiting_result", + providerOrderId: `order_${attempt.id}`, + actorId: "provider:prava", + traceId: "trace-payment", + }); + const approvedMission = await kernel.getMission({ missionId }); + expect(approvedMission.mission).toMatchObject({ + state: "payment_approved", + paymentState: "approved", + orderState: "none", + }); + + const idempotencyKey = `checkout:${randomUUID()}`; + const execution = await attempts.claimCheckout({ + attemptId: attempt.id, + idempotencyKey, + actorId: "service:checkout", + traceId: "trace-checkout", + }); + // Duplicate checkout claim with the same idempotency key returns the same + // execution and does not create a second order row. + const duplicateExecution = await attempts.claimCheckout({ + attemptId: attempt.id, + idempotencyKey, + actorId: "service:checkout", + traceId: "trace-checkout", + }); + expect(duplicateExecution.id).toBe(execution.id); + const orders = await harness.pool.query<{ count: string }>( + "SELECT count(*) FROM orders WHERE mission_id = $1", + [missionId], + ); + expect(orders.rows[0]?.count).toBe("1"); + + // A duplicate claim with a *different* idempotency key is rejected outright. + await expect( + attempts.claimCheckout({ + attemptId: attempt.id, + idempotencyKey: `checkout:${randomUUID()}`, + actorId: "service:checkout", + traceId: "trace-checkout", + }), + ).rejects.toMatchObject({ code: "IDEMPOTENCY_KEY_REUSED" }); + + await attempts.markCheckoutSubmitted({ + executionId: execution.id, + actorId: "service:checkout", + traceId: "trace-checkout", + }); + await attempts.confirmCheckout({ + executionId: execution.id, + finalAmountMinor: reservation.amountMinor, + currency: reservation.currency, + actorId: "service:checkout", + traceId: "trace-checkout", + }); + const completedMission = await kernel.getMission({ missionId }); + expect(completedMission.mission).toMatchObject({ + state: "completed", + orderState: "confirmed", + }); + const finalOrders = await harness.pool.query<{ + count: string; + final_amount_minor: string | null; + }>( + "SELECT count(*), max(final_amount_minor) AS final_amount_minor FROM orders WHERE mission_id = $1", + [missionId], + ); + expect(finalOrders.rows[0]?.count).toBe("1"); + expect(Number(finalOrders.rows[0]?.final_amount_minor)).toBe( + reservation.amountMinor, + ); + }); + + it("rejects an invalid variant before any mission is created, exactly like reloadTrustedProductContext for a text-originated mission", async () => { + const { captures, deps } = buildHarness(); + const capture = await createCapture(captures, { + variantId: "not-a-real-variant", + }); + + await analyzeMarketplaceCapture(capture.id, "trace-job-1", deps); + + const final = await captures.getById(capture.id); + expect(final?.status).toBe("failed"); + expect(final?.failureCode).toBe("VARIANT_NOT_FOUND"); + const missionCount = await harness.pool.query<{ count: string }>( + "SELECT count(*) FROM missions WHERE owner_user_id = $1", + [USER_ID], + ); + expect(missionCount.rows[0]?.count).toBe("0"); + }); + + it("gives a captured product no free pass on budget eligibility: a spoofed low displayed price yields no eligible offers, same as any budget-constrained mission", async () => { + const { kernel, captures, deps } = buildHarness(); + // A displayed price far below the trusted registry price caps the + // resolved budget (resolveCaptureBudget's under_displayed_price preset) + // below the real item price, so the mock-india listing is filtered out + // by MockIndiaMerchantAdapter.discover's budget check exactly as it would + // be for a text-originated mission with too low a stated budget. + const capture = await createCapture(captures, { + preset: "under_displayed_price", + marketplaceContext: marketplaceContext({ displayedPriceMinor: 1 }), + }); + + await analyzeMarketplaceCapture(capture.id, "trace-job-1", deps); + + const final = await captures.getById(capture.id); + expect(final?.status).toBe("mission_created"); + const missionId = final?.missionId; + if (!missionId) throw new Error("expected a mission id"); + + const view = await kernel.getMission({ missionId }); + expect(view.mission.state).toBe("failed"); + expect(view.workflow.outcomeCode).not.toBe("OFFERS_AVAILABLE"); + const offers = await harness.pool.query<{ count: string }>( + "SELECT count(*) FROM offers WHERE mission_id = $1 AND eligible = true", + [missionId], + ); + expect(offers.rows[0]?.count).toBe("0"); + }); + + it("expires a quote exactly like any other mission: BUY fails once the offer's quoteExpiresAt has passed", async () => { + const { kernel, captures, deps } = buildHarness(); + const capture = await createCapture(captures); + await analyzeMarketplaceCapture(capture.id, "trace-job-1", deps); + const missionId = (await captures.getById(capture.id))?.missionId; + if (!missionId) throw new Error("expected a mission id"); + const view = await kernel.getMission({ missionId }); + const offerRow = await harness.pool.query<{ id: string; url: string }>( + `SELECT id, snapshot->'merchant'->>'url' AS url FROM offers + WHERE mission_id = $1 AND eligible = true LIMIT 1`, + [missionId], + ); + const offerId = offerRow.rows[0]?.id; + const merchantUrl = offerRow.rows[0]?.url; + if (!offerId || !merchantUrl || !view.contract) { + throw new Error("expected an eligible offer"); + } + const envelope = () => ({ + commandId: randomUUID(), + actorId: "user:test", + traceId: `trace-${randomUUID()}`, + issuedAt: NOW, + }); + const capability = await kernel.issueCapability({ + ...envelope(), + missionId, + principalId: "checkout:test", + principalKind: "checkout", + allowedTools: ["checkout.execute"], + allowedMerchantHosts: [new URL(merchantUrl).hostname], + maximumAmountMinor: view.contract.spend.maximumTotalMinor, + currency: view.contract.spend.currency, + maximumExecutions: 1, + expiresAt: "2026-08-02T10:00:00.000Z", + }); + await kernel.selectOffer({ + ...envelope(), + missionId, + offerId, + expectedContractVersion: view.contract.version, + expectedContractHash: view.contract.contentHash, + }); + + // The mock-india adapter stamps quoteExpiresAt from real wall-clock time + // (not the injected Clock), so simulate expiry deterministically by + // rewriting the persisted offer snapshot's quoteExpiresAt into the past + // relative to the fake clock's "now" — evaluateAndRankOffers reads that + // field directly and treats it exactly like any other mission's offer. + await harness.pool.query( + `UPDATE offers + SET snapshot = jsonb_set(snapshot, '{price,quoteExpiresAt}', to_jsonb($2::text)) + WHERE id = $1`, + [offerId, "2026-08-01T09:00:00.000Z"], + ); + + await expect( + kernel.requestSpend({ + ...envelope(), + missionId, + offerId, + capabilityLeaseId: capability.id, + principalId: capability.principalId, + expectedContractVersion: view.contract.version, + expectedContractHash: view.contract.contentHash, + }), + ).rejects.toMatchObject({ code: "OFFER_NO_LONGER_ELIGIBLE" }); + }); +}); + +describe("marketplace capture failure isolation", () => { + it("never invokes Prava MCP shopping for a sandbox mock-india capture (analysis dependencies carry no MCP shopping client)", async () => { + const { deps } = buildHarness(); + // Structural guarantee, not a runtime spy: MarketplaceCaptureAnalysisDependencies + // has no Prava/MCP field at all, so there is no call site through which a + // sandbox capture could reach Prava MCP shopping tools. + expect(Object.keys(deps)).not.toContain("prava"); + expect(Object.keys(deps)).not.toContain("mcpProvider"); + }); + + it("surfaces reload failures as a recorded DomainError without ever creating a mission", async () => { + const { captures, deps } = buildHarness(); + const capture = await createCapture(captures, { + vendorId: "not-the-real-vendor", + }); + // analyzeMarketplaceCapture catches reloadTrustedProductContext's + // DomainError itself (it never propagates to the job runner) and records + // its code/message on the capture row. + await expect( + analyzeMarketplaceCapture(capture.id, "trace-job-1", deps), + ).resolves.toBeUndefined(); + const final = await captures.getById(capture.id); + expect(final?.status).toBe("failed"); + expect(final?.failureCode).toBe("VENDOR_MISMATCH"); + }); +}); diff --git a/apps/server/src/marketplace-capture.test.ts b/apps/server/src/marketplace-capture.test.ts index faf61ef..b6acd41 100644 --- a/apps/server/src/marketplace-capture.test.ts +++ b/apps/server/src/marketplace-capture.test.ts @@ -375,6 +375,9 @@ function makeDeps( version: 1, contentHash: "b".repeat(64), })), + recordTopologyDecision: vi.fn(async () => ({ + topology: "solo", + })), }; return { pool: pool as never, @@ -413,6 +416,12 @@ describe("analyzeMarketplaceCapture", () => { expect(final?.missionId).toBe("50000000-0000-4000-8000-000000000001"); expect(deps.kernel.createMission).toHaveBeenCalledTimes(1); expect(deps.kernel.createContract).toHaveBeenCalledTimes(1); + // A topology decision is required before mission.search's workflow + // executor will do anything (loadActiveWorkflow no-ops on a null + // topology_decision), so every capture-created mission must record one, + // exactly like every other mission-creation path (mission.create, direct + // Linq purchase). + expect(deps.kernel.recordTopologyDecision).toHaveBeenCalledTimes(1); expect(deps.enqueueMissionSearch).toHaveBeenCalledTimes(1); }); @@ -434,6 +443,7 @@ describe("analyzeMarketplaceCapture", () => { expect(deps.kernel.createMission).toHaveBeenCalledTimes(1); expect(deps.kernel.createContract).toHaveBeenCalledTimes(1); + expect(deps.kernel.recordTopologyDecision).toHaveBeenCalledTimes(1); }); it("rejects on a vendor/identity conflict without creating a mission", async () => { diff --git a/apps/server/src/marketplace-capture.ts b/apps/server/src/marketplace-capture.ts index a334155..1aba735 100644 --- a/apps/server/src/marketplace-capture.ts +++ b/apps/server/src/marketplace-capture.ts @@ -444,6 +444,28 @@ export async function analyzeMarketplaceCapture( source: "visual_capture", draft, }); + // A captured product is always a single-buyer, immediate purchase with no + // private constraints, independent authority, or external agents — the + // same "solo" topology input every other single-user mission-creation + // path (mission.create, direct Linq purchase) records. Without this call + // the mission.search workflow executor's loadActiveWorkflow() finds + // topology_decision IS NULL and silently no-ops forever, leaving the + // mission stuck in "searching" with no offers ever discovered. + await deps.kernel.recordTopologyDecision({ + commandId: stableUuid("marketplace-capture-topology", capture.id), + actorId, + traceId: jobTraceId, + issuedAt: now.toISOString(), + missionId: mission.id, + input: { + participantCount: 1, + privateConstraints: false, + independentAuthority: false, + externalAgents: false, + merchantRisk: "low", + evidenceDiversityRequired: false, + }, + }); await deps.enqueueMissionSearch({ missionId: mission.id, contractVersion: contract.version, From ad4860476d6f0fee8821dad64c788cb6da70862a Mon Sep 17 00:00:00 2001 From: Shantanav Date: Mon, 3 Aug 2026 00:21:36 +0530 Subject: [PATCH 3/6] fix: cap grounded capture budgets at any explicit user ceiling; guard malformed capture env numbers --- apps/server/src/app.ts | 20 +++++++++--- apps/server/src/marketplace-capture.test.ts | 28 +++++++++++++++++ apps/server/src/marketplace-capture.ts | 34 ++++++++++++++------- 3 files changed, 67 insertions(+), 15 deletions(-) diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index d726f8e..73887e0 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -227,6 +227,16 @@ export type ServerConfig = { marketplaceCaptureRetentionMs: number; }; +function positiveFiniteNumber( + raw: string | undefined, + fallback: number, +): number { + const parsed = Number(raw); + return raw !== undefined && Number.isFinite(parsed) && parsed > 0 + ? parsed + : fallback; +} + function jsonEnvironment( name: string, value: string | undefined, @@ -630,11 +640,13 @@ export function loadServerConfig( ? { webAppBaseUrl: webAppBaseUrl.replace(/\/$/, "") } : {}), ...(capsuleMasterKey ? { capsuleMasterKey } : {}), - marketplaceCaptureMaxBytes: Number( - environment["MARKETPLACE_CAPTURE_MAX_BYTES"] ?? 5 * 1024 * 1024, + marketplaceCaptureMaxBytes: positiveFiniteNumber( + environment["MARKETPLACE_CAPTURE_MAX_BYTES"], + 5 * 1024 * 1024, ), - marketplaceCaptureRetentionMs: Number( - environment["MARKETPLACE_CAPTURE_RETENTION_MS"] ?? 24 * 60 * 60_000, + marketplaceCaptureRetentionMs: positiveFiniteNumber( + environment["MARKETPLACE_CAPTURE_RETENTION_MS"], + 24 * 60 * 60_000, ), }; } diff --git a/apps/server/src/marketplace-capture.test.ts b/apps/server/src/marketplace-capture.test.ts index b6acd41..4b596df 100644 --- a/apps/server/src/marketplace-capture.test.ts +++ b/apps/server/src/marketplace-capture.test.ts @@ -189,6 +189,34 @@ describe("resolveCaptureBudget", () => { maximumTotalMinor: PRODUCT.minimumPriceMinor + 10_000, }); }); + + it("caps the grounded exact_product budget at an explicit ceiling stated in the instruction", () => { + const statedCapMinor = Math.max( + 100, + Math.floor((PRODUCT.minimumPriceMinor + 10_000) / 200), + ); + const result = resolveCaptureBudget({ + preset: "exact_product", + product: PRODUCT, + marketplaceContext, + instruction: `only if under ${statedCapMinor / 100}`, + }); + expect(result).toEqual({ maximumTotalMinor: statedCapMinor }); + }); + + it("caps under_displayed_price at an explicit ceiling stated in the instruction too", () => { + const statedCapMinor = Math.max( + 100, + Math.floor(PRODUCT.minimumPriceMinor / 200), + ); + const result = resolveCaptureBudget({ + preset: "under_displayed_price", + product: PRODUCT, + marketplaceContext, + instruction: `under ${statedCapMinor / 100}`, + }); + expect(result).toEqual({ maximumTotalMinor: statedCapMinor }); + }); }); describe("detectIdentityConflict", () => { diff --git a/apps/server/src/marketplace-capture.ts b/apps/server/src/marketplace-capture.ts index 1aba735..cb46fd2 100644 --- a/apps/server/src/marketplace-capture.ts +++ b/apps/server/src/marketplace-capture.ts @@ -89,32 +89,44 @@ export function resolveCaptureBudget(input: { }): ResolvedCaptureBudget { const trustedPriceMinor = input.variant?.priceMinor ?? input.product.minimumPriceMinor; + // An explicit ceiling in free text always wins, regardless of preset — a + // grounded default must never exceed what the shopper actually asked for. + const statedMaxMinor = parseBudgetFromInstruction( + input.instruction, + input.product.currency, + ); if (input.preset === "under_displayed_price") { const displayedMinor = input.marketplaceContext.visibleProduct.displayedPriceMinor; + const groundedMax = Math.max( + 0, + Math.min(trustedPriceMinor, displayedMinor), + ); return { - maximumTotalMinor: Math.max( - 0, - Math.min(trustedPriceMinor, displayedMinor), - ), + maximumTotalMinor: + statedMaxMinor === undefined + ? groundedMax + : Math.min(groundedMax, statedMaxMinor), }; } if (input.preset === "custom") { - const parsed = parseBudgetFromInstruction( - input.instruction, - input.product.currency, - ); - if (parsed === undefined) { + if (statedMaxMinor === undefined) { return { needsClarification: true, question: "What is the maximum total you'd like Cascade to pay for this, including delivery?", }; } - return { maximumTotalMinor: parsed }; + return { maximumTotalMinor: statedMaxMinor }; } const buffer = DELIVERY_BUFFER_MINOR[input.product.currency] ?? 0; - return { maximumTotalMinor: trustedPriceMinor + buffer }; + const groundedMax = trustedPriceMinor + buffer; + return { + maximumTotalMinor: + statedMaxMinor === undefined + ? groundedMax + : Math.min(groundedMax, statedMaxMinor), + }; } /** From d36253bc9f9f7335a3241c83abf439118ecde808 Mon Sep 17 00:00:00 2001 From: Shantanav Date: Mon, 3 Aug 2026 01:14:09 +0530 Subject: [PATCH 4/6] test: wire marketplace capture integration suite into CI, fix format/fixture drift --- apps/server/package.json | 2 +- apps/server/src/app.integration.test.ts | 3 +++ apps/server/src/chat-pitch-demo.ts | 9 +++++++-- apps/server/src/index.ts | 8 ++++++-- apps/server/src/payments.ts | 12 +++--------- apps/server/src/worker.ts | 3 ++- 6 files changed, 22 insertions(+), 15 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index f91949e..0e51e22 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -7,7 +7,7 @@ "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run --exclude '**/*.integration.test.ts'", - "test:integration": "vitest run src/app.integration.test.ts src/workflow.integration.test.ts --maxWorkers=1", + "test:integration": "vitest run src/app.integration.test.ts src/workflow.integration.test.ts src/marketplace-capture.integration.test.ts --maxWorkers=1", "start": "node dist/index.js", "start:worker": "node dist/worker.js" }, diff --git a/apps/server/src/app.integration.test.ts b/apps/server/src/app.integration.test.ts index 7248b4d..f33aefb 100644 --- a/apps/server/src/app.integration.test.ts +++ b/apps/server/src/app.integration.test.ts @@ -300,6 +300,9 @@ describe("MCP v2 kernel adapter", () => { transport: "streamable-http", audience: "http://127.0.0.1:3001/mcp", shopping: false, + commerce: { + mockIndiaMerchants: false, + }, checkout: { pravaMcp: false, pravaRest: false, diff --git a/apps/server/src/chat-pitch-demo.ts b/apps/server/src/chat-pitch-demo.ts index 9b0562f..58891b1 100644 --- a/apps/server/src/chat-pitch-demo.ts +++ b/apps/server/src/chat-pitch-demo.ts @@ -272,7 +272,9 @@ async function* waitForPravaReceipt(input: { type: "message", role: "cascade", text: `Receipt ready. Mission completed after Prava sandbox approval${ - amount != null ? ` for ₹${(amount / 100).toLocaleString("en-IN")}` : "" + amount != null + ? ` for ₹${(amount / 100).toLocaleString("en-IN")}` + : "" }.`, }; return; @@ -313,7 +315,10 @@ async function* waitForPravaReceipt(input: { ); } } catch (error) { - if (error instanceof DomainError && error.code === "DEMO_MISSION_FAILED") { + if ( + error instanceof DomainError && + error.code === "DEMO_MISSION_FAILED" + ) { throw error; } if ( diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index b54de2b..9a6fe9e 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -14,7 +14,10 @@ const marketplaceCaptureRetentionTimer = void runtime.marketplaceCaptures?.worker .runRetentionSweep() .catch((error) => - console.error("Marketplace capture retention sweep failed", error), + console.error( + "Marketplace capture retention sweep failed", + error, + ), ), 3_600_000, ) @@ -124,7 +127,8 @@ async function close(): Promise { stopLinqRuntime(); if (watchTimer) clearInterval(watchTimer); if (capsuleTimer) clearInterval(capsuleTimer); - if (marketplaceCaptureRetentionTimer) clearInterval(marketplaceCaptureRetentionTimer); + if (marketplaceCaptureRetentionTimer) + clearInterval(marketplaceCaptureRetentionTimer); await app.close(); await runtime.jobs.stop(); await runtime.pool.end(); diff --git a/apps/server/src/payments.ts b/apps/server/src/payments.ts index 3dd905b..2cfb66a 100644 --- a/apps/server/src/payments.ts +++ b/apps/server/src/payments.ts @@ -415,11 +415,7 @@ export class PravaPaymentService { const mcpProvider = this.dependencies.mcpProvider; const checkout = this.dependencies.checkout; // Pure MCP path with checkout gated off cannot proceed (no REST simulator). - if ( - mcpProvider && - !this.dependencies.mcpCheckoutEnabled && - !checkout - ) { + if (mcpProvider && !this.dependencies.mcpCheckoutEnabled && !checkout) { throw new DomainError("CHECKOUT_UNAVAILABLE"); } const attempt = await this.dependencies.attempts.get(input.attemptId); @@ -618,8 +614,7 @@ export class PravaPaymentService { throw error; throw new DomainError("CHECKOUT_RECONCILIATION_REQUIRED"); } - if (!useMcpCheckout) - await this.#sendPendingProviderReport(attempt.id); + if (!useMcpCheckout) await this.#sendPendingProviderReport(attempt.id); return this.dependencies.attempts.get(attempt.id); } @@ -766,8 +761,7 @@ export class PravaPaymentService { }); } if (outcome.status !== "pending") - if (!useMcpCheckout) - await this.#sendPendingProviderReport(attempt.id); + if (!useMcpCheckout) await this.#sendPendingProviderReport(attempt.id); return this.dependencies.attempts.get(attempt.id); } diff --git a/apps/server/src/worker.ts b/apps/server/src/worker.ts index 3e02a9a..d3ec344 100644 --- a/apps/server/src/worker.ts +++ b/apps/server/src/worker.ts @@ -72,7 +72,8 @@ async function close(): Promise { stopLinqRuntime(); if (watchTimer) clearInterval(watchTimer); if (capsuleTimer) clearInterval(capsuleTimer); - if (marketplaceCaptureRetentionTimer) clearInterval(marketplaceCaptureRetentionTimer); + if (marketplaceCaptureRetentionTimer) + clearInterval(marketplaceCaptureRetentionTimer); await runtime.jobs.stop(); await runtime.pool.end(); } From 5343c0c84f55e2e34e2e6544e3e51673b85e8cd4 Mon Sep 17 00:00:00 2001 From: Shantanav Date: Mon, 3 Aug 2026 01:14:41 +0530 Subject: [PATCH 5/6] docs: document visual marketplace + capture feature in build.md --- .env.example | 5 +++++ build.md | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/.env.example b/.env.example index 67f0227..6df3496 100644 --- a/.env.example +++ b/.env.example @@ -112,3 +112,8 @@ PRAVA_MCP_SCOPES_JSON=[] # Optional client/tooling defaults for apps/mcp-demo. CASCADE_MCP_ENDPOINT=http://127.0.0.1:3001/mcp CASCADE_MCP_TOKEN=cascade-local-dev-token + +# Visual marketplace capture (apps/web/src/marketplace, apps/server/src/marketplace-capture.ts). +# Feature is off unless WEB_SESSION_SECRET (above) and OPENAI_API_KEY (above) are both set. +MARKETPLACE_CAPTURE_MAX_BYTES=5000000 +MARKETPLACE_CAPTURE_RETENTION_MS=86400000 diff --git a/build.md b/build.md index d4adf03..c5650d7 100644 --- a/build.md +++ b/build.md @@ -451,3 +451,42 @@ discovery is not configured. A prompt that needs clarification is printed withou See `plan.md` for ownership. The server and external demo client share their MCP schemas, names, and descriptions through `@cascade/contracts`, and the integration suite exercises them together. + +## Visual marketplace + capture (`/marketplace`) + +A demo-facing entry point that lets a shopper browse Cascade's existing sandbox catalog visually, +capture a product card, and create a mission from it — instead of typing a text prompt. + +**Nothing new is invented here.** The marketplace reads the same `MOCK_INDIA_LISTINGS` registry +(`packages/integrations/merchants/src/mock-india-catalog.ts`) that text-originated missions already +search, and a captured product flows through the exact same `CascadeKernel` → `mission.search` → +`MockIndiaMerchantAdapter` → quote → `requestSpend` → Prava REST sandbox → checkout pipeline as any +other mission. There is no capture-specific quote, payment, or checkout code path. + +``` +apps/web/src/marketplace/* Product grid, capture shortcut (⌘/Ctrl+Shift+K), review dialog +apps/server/src/marketplace-*.ts Catalog read, capture API, screenshot storage, vision analysis job +packages/db/migrations/0016-0017 marketplace_captures table +packages/contracts Marketplace/capture Zod schemas, capture.create_marketplace_mission MCP tool +``` + +Flow: capture a product card → upload (behind explicit confirmation, nothing leaves the browser on +Cancel) → server reloads the product/vendor/variant from the registry (client-claimed price/vendor/ +stock is never trusted) → vision model extracts facts for display only, never for identity — a +conflict blocks mission creation rather than overriding the registry → Purchase Contract compiled +with `source: "visual_capture"` and a budget that's the trusted price plus a small delivery buffer, +capped at any explicit ceiling the shopper typed → `CascadeKernel.createMission` → the shared mission +pipeline takes over from there. + +Every screen carries a sandbox notice; no real payment or merchant order is ever created. Screenshots +are stored privately and deleted after `MARKETPLACE_CAPTURE_RETENTION_MS` (default 24h) regardless of +mission outcome. The feature is inert (`GET /v1/marketplace/products` still works; capture endpoints +404-equivalent) unless both `WEB_SESSION_SECRET` and `OPENAI_API_KEY` are set. + +Focused tests: + +```bash +pnpm --filter @cascade/server exec vitest run src/marketplace-capture.test.ts +pnpm --filter @cascade/server exec vitest run src/marketplace-capture.integration.test.ts +pnpm --filter @cascade/web test +``` From a3c91a557d7e4850ce2154987d8a2fb992857cfe Mon Sep 17 00:00:00 2001 From: Shantanav Date: Mon, 3 Aug 2026 05:29:07 +0530 Subject: [PATCH 6/6] fix: update stale MCP tool-catalog count assertions to 13 --- apps/mcp-demo/src/mcp-demo.test.ts | 4 ++-- apps/server/src/app.integration.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/mcp-demo/src/mcp-demo.test.ts b/apps/mcp-demo/src/mcp-demo.test.ts index be1b7cd..51ba14c 100644 --- a/apps/mcp-demo/src/mcp-demo.test.ts +++ b/apps/mcp-demo/src/mcp-demo.test.ts @@ -83,12 +83,12 @@ describe("external MCP demo client", () => { it("validates the governed tool catalog and reports missing tools", async () => { const client = new ExternalMcpClient({ transport: fixtureTransport() }); - await expect(client.listTools()).resolves.toHaveLength(11); + await expect(client.listTools()).resolves.toHaveLength(13); const catalog = validateToolCatalog(toolCatalogResponse); expect(catalog.missing).toEqual([]); expect(catalog.mismatched).toEqual([]); - expect(catalog.tools).toHaveLength(11); + expect(catalog.tools).toHaveLength(13); const partial = validateToolCatalog(toolCatalogResponse.slice(0, 1)); expect(partial.missing).toContain("spend.request"); diff --git a/apps/server/src/app.integration.test.ts b/apps/server/src/app.integration.test.ts index 5cfd408..8c3deaa 100644 --- a/apps/server/src/app.integration.test.ts +++ b/apps/server/src/app.integration.test.ts @@ -607,7 +607,7 @@ describe("MCP v2 kernel adapter", () => { token: TOKEN, }); try { - await expect(live.client.listTools()).resolves.toHaveLength(11); + await expect(live.client.listTools()).resolves.toHaveLength(13); const created = await live.client.createMission({ commandId: randomUUID(), contractCommandId: randomUUID(),