diff --git a/.agents/plans/stripe-limited-preview-walletless-base.plan.md b/.agents/plans/stripe-limited-preview-walletless-base.plan.md new file mode 100644 index 00000000..896472b7 --- /dev/null +++ b/.agents/plans/stripe-limited-preview-walletless-base.plan.md @@ -0,0 +1,173 @@ +--- +name: stripe-limited-preview-walletless-base +overview: Harden the existing wallet-bound Stripe test path for a limited preview, then design walletless identity and Base-compatible card access without representing off-chain card sales as protocol purchases. +todos: + - id: audit-current-stripe-path + content: Verify the current checkout, webhook, refund, entitlement, Base rejection, auth, and operator-documentation behavior against repository truth. + status: completed + - id: harden-preview-activation + content: Add an explicit server checkout flag, production edge-rate-limit acknowledgement, and best-effort route throttling without changing webhook availability. + status: completed + - id: persist-reconciliation + content: Add an additive durable Stripe webhook outcome and operator-review queue for unprocessable fulfillment, partial refunds, and unmatched revocations. + status: completed + - id: add-read-only-ops-monitor + content: Add a read-only preflight/monitor command that reports activation state and unresolved reconciliation items without mutating Stripe or database state. + status: completed + - id: specify-walletless-identity + content: Specify the Google/email identity and account-linking model, including migration boundaries and recovery semantics, without implementing a core entitlement-key change in this PR. + status: completed + - id: specify-base-card-access + content: Specify Base-compatible off-chain access grants that remain distinct from Base protocol receipts and do not enable Base mainnet. + status: completed + - id: verify-limited-preview + content: Run focused tests and the full web quality gate, update policy/runbook documentation, and record any manual or live checks not performed. + status: completed + - id: handle-empty-monitor-bootstrap + content: Make the read-only preview monitor report zero reconciliation items before the first webhook creates the additive table; verified live after deployment. + status: completed +isProject: false +--- + +# Stripe Limited Preview, Walletless Identity, and Base Compatibility + +## Goal + +Move the existing Stripe test-mode prototype toward a deliberately limited preview by making activation explicit, preserving webhook events that need operator action, and providing a read-only operational check. In parallel, define the next identity and Base-access model so card buyers can eventually use AgentVouch without a browser wallet while card sales remain visibly separate from on-chain protocol settlement. + +## Scope + +### In scope + +- Keep the existing wallet-signed Stripe checkout and refund/dispute revocation behavior. +- Require a server-only checkout activation flag in addition to Stripe credentials and the render-affecting public UI flag. +- Require an explicit production acknowledgement that a Vercel Firewall/WAF rate limit is installed; add the existing in-memory limiter only as defense in depth. +- Persist webhook outcomes that need review in an additive, idempotent database table; do not rely on logs as the queue. +- Add a read-only operator preflight/monitor command. +- Document the recommended walletless identity and Base-compatible access-grant model, including migration and rollout gates. +- Keep the stable Stripe test preview and production activation as separate operational decisions. + +### Out of scope + +- Enabling Stripe checkout on the production deployment. +- Enabling `eip155:8453`, changing the Phase 10 Base mainnet gate, or treating Base Sepolia proof as Base mainnet readiness. +- Converting fiat to USDC, settling a Stripe purchase on-chain, funding author/voucher economics, or representing Stripe receipts as Base/Solana purchase receipts. +- Adding an auth dependency, adopting a hosted auth vendor, or changing the entitlement primary key in this implementation pass. +- Automating author payouts, partial-refund policy decisions, or dispute-won reinstatement. +- Creating Vercel Firewall rules from code; the operator acknowledgement only prevents accidental production activation before the external rule exists. + +## Current State (verified 2026-07-15) + +- `web/app/api/stripe/checkout/route.ts` requires a wallet signature, rejects Base protocol listings, refuses duplicate access, and creates a Stripe Checkout Session for the listing price. +- `web/app/api/stripe/webhook/route.ts` verifies the raw-body Stripe signature, fulfills successful payments, revokes on full refund/dispute, and keeps replayed refunded payments revoked. +- Unprocessable webhook events and partial refunds are currently visible only in logs even though policy calls for a reconciliation queue. +- `web/lib/rateLimit.ts` is explicitly per-instance and cannot substitute for Vercel Firewall/WAF or a shared rate-limit store. +- Existing GitHub OAuth is a publisher/profile session, while Phantom embedded Google sign-in supplies an embedded Solana wallet. Neither is yet a chain-neutral buyer-account key for email-only card access. +- Base card checkout is intentionally rejected because Base downloads verify chain-qualified purchase state; minting a wallet-pubkey entitlement would charge for access the Base gate cannot redeem. + +## Design Decisions + +### 2026-07-15 — Keep checkout activation and webhook processing separate + +Webhook processing must remain available whenever Stripe credentials and the webhook secret are configured so refunds, disputes, retries, and delayed payment events continue to be processed after checkout is disabled. Session creation gets a separate `AGENTVOUCH_STRIPE_CHECKOUT_ENABLED=true` server flag. Production additionally requires `AGENTVOUCH_STRIPE_EDGE_RATE_LIMIT_READY=true` as an explicit acknowledgement that the external Vercel rate limit exists. + +### 2026-07-15 — Durable review queue, no raw payload storage + +Persist event id/type, object id, payment reference, skill/buyer identifiers when available, outcome, reason, and small non-sensitive details. Do not store raw Stripe webhook payloads or customer email in the queue. Event id is the idempotency key; retries update occurrence and last-seen timestamps. + +### 2026-07-15 — Walletless UX should use an account identity, not a fake wallet + +The recommended durable model is a first-party buyer account plus linked identities (Google/email, GitHub, embedded wallets, Base addresses, Solana pubkeys). Access grants reference the account and may also carry a wallet/chain redemption binding. Do not synthesize Solana or EVM addresses from email, and do not overload the legacy `(skill_db_id, buyer_pubkey)` entitlement primary key without a guarded migration plan. + +For the lowest-risk onboarding experiment before that migration, Phantom embedded Google sign-in can provide a walletless-looking Solana flow because the recoverable embedded wallet remains the actual buyer key. That is not a chain-neutral email entitlement and must not be described as one. + +### 2026-07-15 — Base card access is an off-chain grant + +A future Stripe card purchase for a Base-listed skill should create a chain-neutral marketplace access grant after verified payment. It must not create a Base purchase id, protocol receipt, author proceeds, voucher rewards, or dispute state. Base USDC/x402 remains the protocol purchase path. Implement and smoke this on Base Sepolia first; Base mainnet stays blocked by `docs/MAINNET_READINESS.md`. + +## Implementation Files + +- `web/lib/stripe.ts`: server activation predicate and documented environment gates. +- `web/app/api/stripe/checkout/route.ts`: server activation check and best-effort per-IP/per-wallet throttling. +- `web/lib/stripeReconciliation.ts`: additive schema, idempotent outcome writes, and read-only unresolved-item queries. +- `web/app/api/stripe/webhook/route.ts`: durable outcomes for fulfillment, revocation, unprocessable events, partial refunds, and unmatched revocations. +- `web/scripts/stripe-limited-preview-ops.ts`: read-only `preflight` and `monitor` modes. +- `web/package.json`: operator command only; no dependency changes. +- `web/__tests__/lib/stripe.test.ts`: activation-gate behavior. +- `web/__tests__/api/stripe-routes.test.ts`: rate limiting and durable webhook outcomes. +- `web/__tests__/lib/stripeReconciliation.test.ts`: alert and query-shape behavior. +- `web/__tests__/scripts/stripe-limited-preview-ops.test.ts`: read-only mode parsing and activation preflight. +- `docs/STRIPE_MPP_POLICY.md`, `docs/STRIPE_TEST_MODE_ROLLOUT.md`, and `docs/STRIPE_FEASIBILITY.md`: exact preview gates and walletless/Base decision record. + +## Verification + +Use Node 24 for every web command: + +```bash +export PATH="$HOME/.nvm/versions/node/v24.1.0/bin:$PATH" +npm run format:check +npm run lint --workspace @agentvouch/web +npm run typecheck --workspace @agentvouch/web +npm run test --workspace @agentvouch/web +npm exec --workspace @agentvouch/web -- next build --webpack +``` + +Focused checks before the full gate: + +```bash +npm run test --workspace @agentvouch/web -- __tests__/lib/stripe.test.ts __tests__/api/stripe-routes.test.ts __tests__/lib/stripeReconciliation.test.ts __tests__/scripts/stripe-limited-preview-ops.test.ts +npm run stripe:ops --workspace @agentvouch/web -- preflight +npm run stripe:ops --workspace @agentvouch/web -- monitor +``` + +`monitor` is read-only. A live monitor needs the intended deployment's `DATABASE_URL` and Stripe environment; local verification may cover parsing/alerts without querying production. + +## Rollout + +1. Merge code and documentation with checkout flags off. +2. Deploy a preview with test-mode Stripe keys, `AGENTVOUCH_STRIPE_CHECKOUT_ENABLED=true`, and `NEXT_PUBLIC_STRIPE_CHECKOUT_ENABLED=true`. +3. Repeat card payment, entitlement, second-wallet rejection, refund revocation, replay, partial-refund queue, and monitor checks in test mode. +4. Install and verify a Vercel Firewall/WAF rate limit for `POST /api/stripe/checkout`; only then set `AGENTVOUCH_STRIPE_EDGE_RATE_LIMIT_READY=true` on a production candidate. +5. Treat production activation, walletless identity migration, and Base card-access implementation as separately approved gates. + +## Rollback + +- Set `AGENTVOUCH_STRIPE_CHECKOUT_ENABLED=false` and `NEXT_PUBLIC_STRIPE_CHECKOUT_ENABLED=false`, then redeploy. Keep Stripe secrets and the webhook route active until all outstanding payments, refunds, disputes, and delayed events are settled. +- Do not delete reconciliation rows during rollback; resolve them with an operator note after investigation. +- If a preview deployment regresses, keep the previously verified stable preview alias and revert only this feature branch. + +## Open Blockers + +- Production author payout, tax/KYC, and custody policy is not approved. +- Partial-refund behavior and dispute-won reinstatement remain manual policy decisions. +- The walletless account/identity-link schema is a core identity change and needs a separately reviewed guarded migration plan. +- Base card access needs a new chain-neutral access-grant seam and browser/API regression tests; it must not weaken Base protocol purchase verification. +- The external Vercel Firewall/WAF rule must be created and verified before production checkout activation. + +## Verification Results (2026-07-15) + +- Focused Stripe/reconciliation suite: 4 files, 32 tests passed. +- Full web Vitest suite: 107 files, 717 tests passed. +- `npm run format:check`: passed. +- Web ESLint: passed. +- Web typecheck: passed. +- `npm exec --workspace @agentvouch/web -- next build --webpack`: passed. The build emitted the pre-existing `ox/tempo` dynamic-dependency warning and sandbox DNS fallback warnings for Neon/Helius during static generation; page generation and build completion still succeeded. +- Read-only Stripe preflight command: passed with placeholder configured values and reported no secret values. +- Not run: live `monitor` against the preview/production database, webhook creation of the new additive table, Vercel Firewall rule verification, a new live Stripe payment, or any deployment. Those remain rollout checks, not local-code verification. + +### 2026-07-16 live-preview divergence + +The first branch-scoped preview monitor reached the intended database but failed because `stripe_webhook_outcomes` did not yet exist. The monitor deliberately avoids bootstrap DDL, so it must use a read-only `to_regclass` check and treat a missing table as an empty pre-first-event state. This follow-up does not change webhook schema creation or production activation. + +## Live Preview Verification Results (2026-07-16) + +- Signed commit `590efbe` deployed successfully as Vercel preview deployment `dpl_4GQ9FUKYq7m2jT543LdzLmjQpuzz`; the build used branch `ops/stripe-test-listing` and exact commit `590efbe`. +- The read-only monitor passed before and after the payment smoke with checkout enabled, zero blockers, zero open review items, and zero alerts. Vercel does not download branch-scoped sensitive values to local `env run`, so non-secret presence markers were supplied only for the monitor's boolean activation checks; live route probes separately proved the deployed server and webhook configuration. +- Stable preview alias `https://agentvouch-stripe-test-listing.vercel.app` now resolves to the verified deployment. Production was not promoted and production flags were not changed. +- Dedicated buyer `asuavUDGmrVHr4oD1b4QtnnXgtnEcBa8qdkfZz7WZgw` was denied raw access with `402` before payment, then completed Stripe test Checkout session `cs_test_a1FXhQqfS6rTcdaWg5TnODCP8FvZvl4JafYwY7x2qisD61p72V3jOa5JSn` for $1.00 using Stripe's successful interactive test card. +- Stripe reported payment intent `pi_3Tu4lNA2jEYsGvGP01cbtB2C` as paid. The append-only receipt matched `stripe:pi_3Tu4lNA2jEYsGvGP01cbtB2C`, `amount_micros=1000000`, `payment_flow=stripe-mpp-offchain`, `recipient_ata=stripe-offchain`, and `currency_mint=USD`; protocol purchase, settlement, EVM, and x402 fields remained null. +- The buyer's signed raw download changed from `402` to `200`, returned 349 bytes of Markdown, and matched the stored content SHA-256 `99dfd32607fe61c12aeea6ec1c3c59434ab450da5b16f86a93056fbe71cee148`. +- A duplicate checkout attempt returned `409` before creating another session, and an unrelated fresh wallet remained denied with `402`. +- Invalid-signature webhook and unauthenticated checkout probes returned `400` and `401` respectively. The exact deployment had no error-level Vercel logs during the smoke window. +- Full Stripe test refund `re_3Tu4lNA2jEYsGvGP0DKG17jv` succeeded for the same payment intent. The buyer's signed raw access returned from `200` to `402`, the entitlement recorded `revoked_reason=stripe-refund`, and the append-only receipt count remained exactly one. +- The post-refund read-only monitor still reported zero blockers, zero open review items, and zero alerts; the stable preview had no error-level Vercel logs during the revocation window. diff --git a/docs/STRIPE_FEASIBILITY.md b/docs/STRIPE_FEASIBILITY.md index afb9a7be..34a48c89 100644 --- a/docs/STRIPE_FEASIBILITY.md +++ b/docs/STRIPE_FEASIBILITY.md @@ -110,13 +110,15 @@ Does: purchase state, so the pubkey-keyed off-chain entitlement would be unredeemable). - Webhook response policy: permanently-unprocessable events (bad metadata, - amount mismatch, deleted skill) are ACKed with 200 plus a logged reason so - Stripe stops retrying; non-2xx is reserved for signature failures and - transient errors. An existing entitlement is never overwritten — late or + amount mismatch, deleted skill) are durably queued before they are ACKed with + 200 so Stripe stops retrying; non-2xx is reserved for signature failures, + transient errors, and failure to persist the outcome. An existing entitlement is never overwritten — late or duplicate Stripe webhooks ack `alreadyEntitled` instead of clobbering on-chain purchase provenance in the entitlement upsert. -- Is feature-flagged: every entry point no-ops with 501 unless both - `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` are set. +- Checkout session creation is separately feature-flagged: it no-ops with 501 + unless both Stripe secrets and `AGENTVOUCH_STRIPE_CHECKOUT_ENABLED=true` are + set; production also requires the edge-rate-limit acknowledgement. Webhook + processing deliberately remains enabled when only checkout creation is off. Does **not** (deliberately out of scope — these are the Tier 2/3 hard parts): @@ -126,11 +128,12 @@ Does **not** (deliberately out of scope — these are the Tier 2/3 hard parts): - No email-only buyer identity. Buyers still need a wallet signature so the off-chain Stripe entitlement can be redeemed through the existing download auth path — see Obstacle 1. -- Minimal refund / chargeback handling only: `charge.refunded` (full) and +- Limited-preview refund / chargeback handling: `charge.refunded` (full) and `charge.dispute.created` revoke the wallet-bound entitlement - (`usdc_purchase_entitlements.revoked_at`), partial refunds are logged, and a - genuinely new payment re-mints. No automated reconciliation beyond logs and - the receipt table's `UNIQUE(payment_tx_signature)` idempotency. + (`usdc_purchase_entitlements.revoked_at`), partial refunds are durably queued, + and a genuinely new payment re-mints. The read-only operator monitor surfaces + unresolved items; partial-refund decisions and dispute-won reinstatement are + still manual. ## The hard parts (Tier 2 / Tier 3) @@ -205,9 +208,15 @@ and settled on-chain. Worth an explicit product decision before Tier 2. - `STRIPE_WEBHOOK_SECRET` — `whsec_...`, for webhook signature verification. Required before checkout is enabled so paid sessions cannot be created without a fulfillment path. +- `AGENTVOUCH_STRIPE_CHECKOUT_ENABLED` — server-only session-creation gate. + Keep webhooks configured when this is false so delayed events and reversals + still process. - `NEXT_PUBLIC_STRIPE_CHECKOUT_ENABLED` — set to `true` to render card checkout controls. Keep this aligned with the server secrets above and redeploy after changing it. +- `AGENTVOUCH_STRIPE_EDGE_RATE_LIMIT_READY` — required in Vercel production as + an operator acknowledgement that a real Vercel Firewall/WAF rate limit is + installed for `POST /api/stripe/checkout`. It does not create the rule. - `STRIPE_API_BASE` — optional, defaults to `https://api.stripe.com`. - `AGENTVOUCH_PUBLIC_BASE_URL` — optional, for checkout success/cancel URLs; falls back to `NEXT_PUBLIC_APP_URL`, then the request origin. diff --git a/docs/STRIPE_MPP_POLICY.md b/docs/STRIPE_MPP_POLICY.md index c16e2b79..177f94cd 100644 --- a/docs/STRIPE_MPP_POLICY.md +++ b/docs/STRIPE_MPP_POLICY.md @@ -33,8 +33,15 @@ on-chain settlement design is approved. Do not enable Stripe checkout unless all of the following are true: - `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` are configured together. +- `AGENTVOUCH_STRIPE_CHECKOUT_ENABLED=true` is set only on deployments where + new Checkout Sessions should be created. Disabling this flag must not disable + webhook processing for outstanding payments, refunds, disputes, or retries. - `NEXT_PUBLIC_STRIPE_CHECKOUT_ENABLED=true` is set only on deployments where those server secrets and the webhook endpoint are active. +- Production additionally has a verified Vercel Firewall/WAF rate limit on + `POST /api/stripe/checkout` and + `AGENTVOUCH_STRIPE_EDGE_RATE_LIMIT_READY=true`. The route's in-memory limit + is defense in depth, not the distributed control. - Webhook delivery is monitored and failed webhook retries are visible. - Operators can reconcile Stripe payment ids against `usdc_purchase_receipts.payment_tx_signature`. @@ -50,6 +57,25 @@ message. Re-downloads use the existing `X-AgentVouch-Auth` raw download flow. Email-only buyers remain out of scope until there is an identity link table or customer session model that can map Stripe customers to an AgentVouch buyer key. +### Walletless identity direction + +The durable design should introduce a chain-neutral buyer account with linked +identities (verified Google/email, GitHub, embedded wallet, Solana address, and +Base address) plus an explicit marketplace access-grant record. Do not derive a +fake Solana or EVM address from an email, and do not treat a Stripe customer id +as a wallet address. + +Phantom embedded Google sign-in is a useful lower-risk onboarding experiment: +the user does not need an extension or seed phrase, but the embedded Solana +wallet remains the actual entitlement key. That improves UX without solving the +chain-neutral email identity model. + +For Base-listed skills, a future card purchase should create an off-chain +marketplace access grant redeemable by the signed-in buyer account. It must not +create a Base purchase id, claim protocol settlement, or weaken the existing +Base USDC/x402 verification path. Implement and verify that seam on Base Sepolia +before any separately approved Base mainnet work. + ## Payouts Until Stripe Connect or an explicit treasury payout process exists, Stripe @@ -120,8 +146,12 @@ Before treating Stripe MPP as production-ready, resolve: - Refund and chargeback webhook handling. (Shipped: full-refund and dispute revocation; partial refunds and dispute-won reinstatement remain manual.) - Entitlement revocation schema (shipped: `revoked_at`/`revoked_reason`) and UI copy for revoked buyers. - Author payout process, including tax/KYC responsibilities. -- Operator reconciliation dashboard or runbook. -- Rate limits and abuse monitoring on checkout/session creation. +- Operator reconciliation dashboard or runbook. (Shipped for limited preview: + durable webhook outcome queue plus read-only `npm run stripe:ops --workspace +@agentvouch/web -- monitor`; a richer dashboard remains optional.) +- Rate limits and abuse monitoring on checkout/session creation. (Shipped in + code: per-instance defense-in-depth limit and production activation gate; + external Vercel Firewall/WAF rule still requires operator setup and proof.) - Public documentation that separates card checkout from protocol settlement. Use `docs/STRIPE_TEST_MODE_ROLLOUT.md` as the test-mode activation checklist. diff --git a/docs/STRIPE_TEST_MODE_ROLLOUT.md b/docs/STRIPE_TEST_MODE_ROLLOUT.md index e749e129..f4bc8f54 100644 --- a/docs/STRIPE_TEST_MODE_ROLLOUT.md +++ b/docs/STRIPE_TEST_MODE_ROLLOUT.md @@ -16,6 +16,9 @@ purchase state. - `STRIPE_SECRET_KEY` uses a test-mode key. - `STRIPE_WEBHOOK_SECRET` is configured from the matching test-mode webhook endpoint. +- `AGENTVOUCH_STRIPE_CHECKOUT_ENABLED=true` enables session creation on the + test deployment. Leave the webhook configured when this flag is later turned + off so outstanding refunds/disputes still process. - `NEXT_PUBLIC_STRIPE_CHECKOUT_ENABLED=true` is set for the same deployment. - `AGENTVOUCH_PUBLIC_BASE_URL` points at the preview or local tunnel that Stripe redirects back to. @@ -25,6 +28,8 @@ purchase state. `charge.dispute.created`. - Operators can access Stripe Dashboard test events and Vercel/API logs for the checkout and webhook routes. +- `npm run stripe:ops --workspace @agentvouch/web -- preflight` passes in the + deployment environment. ## Happy-Path Test @@ -43,14 +48,16 @@ purchase state. ## Negative Tests - Missing `STRIPE_SECRET_KEY` or missing `STRIPE_WEBHOOK_SECRET` returns 501. +- Missing `AGENTVOUCH_STRIPE_CHECKOUT_ENABLED=true` returns 501 for checkout + creation without disabling webhook processing. - Checkout without wallet auth returns 401. - Checkout auth signed for a different skill is rejected. - Webhook with an invalid Stripe signature is rejected. - Webhook with non-USD currency is acked with an `ignored` reason and does not grant entitlement. - Webhook with amount mismatch is acked with an `ignored` reason (so Stripe - stops retrying) and does not grant entitlement; the reason is logged for the - reconciliation queue. + stops retrying) and does not grant entitlement; the reason is persisted in + `stripe_webhook_outcomes` for operator reconciliation. - Duplicate webhook delivery is idempotent. A second captured payment is kept as an append-only receipt for reconciliation without overwriting the existing entitlement provenance. @@ -68,6 +75,17 @@ purchase state. ## Reconciliation Checks +Run the read-only monitor in the same environment as the intended database: + +```bash +npm run stripe:ops --workspace @agentvouch/web -- monitor +``` + +It exits non-zero for activation blockers or unresolved review items. Resolve +items only after comparing the Stripe event/payment id with the receipt, +entitlement, refund/revocation marker, and expected policy; the monitor never +mutates Stripe or database state. + - Dashboard payment id matches `payment_tx_signature`. - Stripe amount, metadata `price_usdc_micros`, and DB `amount_micros` match. - `payment_flow` is exactly `stripe-mpp-offchain`. @@ -92,13 +110,16 @@ AgentVouch wallet, not a Base-native protocol purchase. - Partial-refund and dispute-won reconciliation (full-refund/dispute revocation is handled). - Entitlement suspension/revocation status fields. -- Operator reconciliation queue for paid-but-not-entitled and - refunded-but-still-entitled cases. +- Richer operator dashboard and resolution workflow. The limited-preview + baseline now persists review items and exposes the read-only monitor. - Author payout policy: Stripe Connect, manual operator payout, or fiat -> USDC protocol settlement. - Public support copy that separates card refunds from protocol disputes/refunds. -- Abuse controls on checkout/session creation. -- Monitoring for webhook failures and entitlement-write failures. +- Verified external Vercel Firewall/WAF rule on checkout/session creation. The + route-level per-instance limiter is defense in depth only. +- Monitoring for webhook failures and entitlement-write failures. The durable + queue covers webhook events the app received; Stripe Dashboard delivery + failures remain a separate operator alert/source of truth. ## Exit Criteria @@ -110,3 +131,5 @@ Test-mode Stripe can move from prototype to limited preview only when: - Product copy labels the path as card checkout / off-chain entitlement. - Metrics exclude Stripe MPP receipts from protocol purchase, voucher yield, author proceeds, and dispute recovery totals. +- Production is still blocked until the Vercel Firewall/WAF rule is verified + and `AGENTVOUCH_STRIPE_EDGE_RATE_LIMIT_READY=true` is explicitly set. diff --git a/web/__tests__/api/stripe-routes.test.ts b/web/__tests__/api/stripe-routes.test.ts index 31b9d3c6..e825d281 100644 --- a/web/__tests__/api/stripe-routes.test.ts +++ b/web/__tests__/api/stripe-routes.test.ts @@ -3,6 +3,7 @@ import { NextRequest } from "next/server"; const mocks = vi.hoisted(() => ({ createCheckoutSession: vi.fn(), + getStripeCheckoutActivation: vi.fn(), isStripeEnabled: vi.fn(), verifyAndParseWebhook: vi.fn(), verifyWalletSignature: vi.fn(), @@ -12,6 +13,8 @@ const mocks = vi.hoisted(() => ({ recordAndApplyUsdcPaymentRevocation: vi.fn(), getUsdcPurchaseEntitlementStatus: vi.fn(), hasUsdcPurchaseReceiptForPaymentRef: vi.fn(), + checkRateLimit: vi.fn(), + recordStripeWebhookOutcome: vi.fn(), })); vi.mock("@/lib/db", () => ({ @@ -26,12 +29,23 @@ vi.mock("@/lib/stripe", () => ({ STRIPE_MIN_CHARGE_USD_CENTS: 50, createCheckoutSession: (...args: unknown[]) => mocks.createCheckoutSession(...args), + getStripeCheckoutActivation: () => mocks.getStripeCheckoutActivation(), isStripeEnabled: () => mocks.isStripeEnabled(), usdcMicrosToUsdCents: (micros: bigint) => Number((micros + 5000n) / 10000n), verifyAndParseWebhook: (...args: unknown[]) => mocks.verifyAndParseWebhook(...args), })); +vi.mock("@/lib/rateLimit", () => ({ + checkRateLimit: (...args: unknown[]) => mocks.checkRateLimit(...args), + clientIpFromRequest: () => "127.0.0.1", +})); + +vi.mock("@/lib/stripeReconciliation", () => ({ + recordStripeWebhookOutcome: (...args: unknown[]) => + mocks.recordStripeWebhookOutcome(...args), +})); + vi.mock("@/lib/auth", async (importOriginal) => { const actual = await importOriginal(); return { @@ -154,7 +168,20 @@ function paidSessionEvent( describe("Stripe checkout and webhook routes", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.getStripeCheckoutActivation.mockReturnValue({ + enabled: true, + stripeConfigured: true, + serverFlagEnabled: true, + productionEdgeRateLimitReady: true, + production: false, + }); mocks.isStripeEnabled.mockReturnValue(true); + mocks.checkRateLimit.mockReturnValue({ + ok: true, + remaining: 4, + retryAfterSeconds: 0, + }); + mocks.recordStripeWebhookOutcome.mockResolvedValue(undefined); mocks.createCheckoutSession.mockResolvedValue({ id: "cs_test_123", url: "https://checkout.stripe.test/cs_test_123", @@ -190,6 +217,44 @@ describe("Stripe checkout and webhook routes", () => { expect(mockSql).not.toHaveBeenCalled(); }); + it("requires the server-side checkout activation gate", async () => { + mocks.getStripeCheckoutActivation.mockReturnValue({ + enabled: false, + stripeConfigured: true, + serverFlagEnabled: false, + productionEdgeRateLimitReady: true, + production: false, + }); + + const res = await checkoutPOST( + checkoutRequest({ skillId, auth: signedCheckoutAuth() }) + ); + const body = await res.json(); + + expect(res.status).toBe(501); + expect(body.error).toContain("AGENTVOUCH_STRIPE_CHECKOUT_ENABLED"); + expect(mocks.createCheckoutSession).not.toHaveBeenCalled(); + }); + + it("rate limits session creation before database work", async () => { + mocks.checkRateLimit.mockReturnValueOnce({ + ok: false, + remaining: 0, + retryAfterSeconds: 42, + }); + + const res = await checkoutPOST( + checkoutRequest({ skillId, auth: signedCheckoutAuth() }) + ); + const body = await res.json(); + + expect(res.status).toBe(429); + expect(res.headers.get("Retry-After")).toBe("42"); + expect(body.error).toContain("Too many"); + expect(mockSql).not.toHaveBeenCalled(); + expect(mocks.createCheckoutSession).not.toHaveBeenCalled(); + }); + it("binds checkout sessions to the signed buyer wallet, price, and amount", async () => { const res = await checkoutPOST( checkoutRequest({ @@ -322,6 +387,13 @@ describe("Stripe checkout and webhook routes", () => { amountMicros: "1000000", paymentFlow: "stripe-mpp-offchain", }); + expect(mocks.recordStripeWebhookOutcome).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: "evt_1", + outcome: "fulfilled", + needsReview: false, + }) + ); }); it("does not fulfill a paid session without a payment intent", async () => { @@ -368,6 +440,13 @@ describe("Stripe checkout and webhook routes", () => { expect(res.status).toBe(200); expect(body.ignored).toContain("charged amount does not match"); expect(mocks.recordRevocableUsdcPurchaseReceipt).not.toHaveBeenCalled(); + expect(mocks.recordStripeWebhookOutcome).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: "evt_1", + outcome: "needs-review", + needsReview: true, + }) + ); }); it("acks unpaid completed sessions without minting (async payment flow)", async () => { @@ -425,6 +504,13 @@ describe("Stripe checkout and webhook routes", () => { "stripe:pi_test_123", "stripe-refund" ); + expect(mocks.recordStripeWebhookOutcome).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: "evt_2", + outcome: "revoked", + needsReview: false, + }) + ); }); it("revokes the entitlement when a dispute is opened", async () => { @@ -465,6 +551,28 @@ describe("Stripe checkout and webhook routes", () => { expect(res.status).toBe(200); expect(body.ignored).toBe("partial refund"); expect(mocks.recordAndApplyUsdcPaymentRevocation).not.toHaveBeenCalled(); + expect(mocks.recordStripeWebhookOutcome).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: "evt_4", + outcome: "needs-review", + reason: "partial refund", + }) + ); + }); + + it("returns 500 when a terminal outcome cannot be persisted", async () => { + mocks.recordStripeWebhookOutcome.mockRejectedValueOnce( + new Error("database unavailable") + ); + mocks.verifyAndParseWebhook.mockReturnValue( + paidSessionEvent({ amount_total: 99 }) + ); + + const res = await webhookPOST(webhookRequest({})); + const body = await res.json(); + + expect(res.status).toBe(500); + expect(body.error).toContain("persist Stripe webhook outcome"); }); it("does not re-mint when a revoked payment's webhook is replayed", async () => { diff --git a/web/__tests__/lib/stripe.test.ts b/web/__tests__/lib/stripe.test.ts index 446901a5..91c6cdf1 100644 --- a/web/__tests__/lib/stripe.test.ts +++ b/web/__tests__/lib/stripe.test.ts @@ -2,6 +2,7 @@ import { createHmac } from "node:crypto"; import { beforeEach, describe, expect, it } from "vitest"; import { + getStripeCheckoutActivation, isStripeCheckoutUiEnabled, isStripeEnabled, verifyAndParseWebhook, @@ -14,6 +15,9 @@ describe("stripe helpers", () => { delete process.env.STRIPE_WEBHOOK_SECRET; delete process.env.STRIPE_API_BASE; delete process.env.NEXT_PUBLIC_STRIPE_CHECKOUT_ENABLED; + delete process.env.AGENTVOUCH_STRIPE_CHECKOUT_ENABLED; + delete process.env.AGENTVOUCH_STRIPE_EDGE_RATE_LIMIT_READY; + delete process.env.VERCEL_ENV; }); it("uses a public flag for render-affecting checkout UI", () => { @@ -35,6 +39,33 @@ describe("stripe helpers", () => { expect(isStripeEnabled()).toBe(true); }); + it("keeps checkout behind a separate server activation flag", () => { + process.env.STRIPE_SECRET_KEY = "sk_test_123"; + process.env.STRIPE_WEBHOOK_SECRET = "whsec_123"; + + expect(getStripeCheckoutActivation().enabled).toBe(false); + + process.env.AGENTVOUCH_STRIPE_CHECKOUT_ENABLED = "true"; + expect(getStripeCheckoutActivation()).toMatchObject({ + enabled: true, + stripeConfigured: true, + serverFlagEnabled: true, + productionEdgeRateLimitReady: true, + }); + }); + + it("requires an edge-rate-limit acknowledgement in production", () => { + process.env.STRIPE_SECRET_KEY = "sk_live_123"; + process.env.STRIPE_WEBHOOK_SECRET = "whsec_123"; + process.env.AGENTVOUCH_STRIPE_CHECKOUT_ENABLED = "true"; + process.env.VERCEL_ENV = "production"; + + expect(getStripeCheckoutActivation().enabled).toBe(false); + + process.env.AGENTVOUCH_STRIPE_EDGE_RATE_LIMIT_READY = "true"; + expect(getStripeCheckoutActivation().enabled).toBe(true); + }); + it("rounds USDC micros into Stripe USD cents", () => { expect(usdcMicrosToUsdCents(1_000_000n)).toBe(100); expect(usdcMicrosToUsdCents(10_000n)).toBe(1); diff --git a/web/__tests__/lib/stripeReconciliation.test.ts b/web/__tests__/lib/stripeReconciliation.test.ts new file mode 100644 index 00000000..88c40f57 --- /dev/null +++ b/web/__tests__/lib/stripeReconciliation.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + sql: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + sql: () => mocks.sql(), +})); + +import { + buildStripeReconciliationAlerts, + listOpenStripeReconciliationItemsReadOnly, + type StripeReconciliationItem, +} from "@/lib/stripeReconciliation"; + +function item(firstSeenAt: string): StripeReconciliationItem { + return { + eventId: "evt_1", + eventType: "checkout.session.completed", + objectId: "cs_1", + paymentRef: "stripe:pi_1", + skillDbId: null, + buyerKey: null, + outcome: "needs-review", + reason: "charged amount does not match checkout metadata price", + details: {}, + occurrenceCount: 1, + firstSeenAt, + lastSeenAt: firstSeenAt, + }; +} + +describe("Stripe reconciliation alerts", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("escalates unresolved review items after the critical age", () => { + const now = Date.parse("2026-07-15T12:30:00.000Z"); + + expect( + buildStripeReconciliationAlerts( + [item("2026-07-15T12:20:00.000Z")], + now + )[0] + ).toMatchObject({ severity: "warning", eventId: "evt_1" }); + expect( + buildStripeReconciliationAlerts( + [item("2026-07-15T12:00:00.000Z")], + now + )[0] + ).toMatchObject({ severity: "critical", eventId: "evt_1" }); + }); + + it("reports an empty read-only state before the webhook table exists", async () => { + const query = vi.fn().mockResolvedValue([{ table_name: null }]); + mocks.sql.mockReturnValue(query); + + await expect(listOpenStripeReconciliationItemsReadOnly()).resolves.toEqual( + [] + ); + expect(query).toHaveBeenCalledTimes(1); + }); + + it("queries unresolved outcomes after the webhook table exists", async () => { + const query = vi + .fn() + .mockResolvedValueOnce([{ table_name: "stripe_webhook_outcomes" }]) + .mockResolvedValueOnce([]); + mocks.sql.mockReturnValue(query); + + await expect(listOpenStripeReconciliationItemsReadOnly()).resolves.toEqual( + [] + ); + expect(query).toHaveBeenCalledTimes(2); + }); +}); diff --git a/web/__tests__/scripts/stripe-limited-preview-ops.test.ts b/web/__tests__/scripts/stripe-limited-preview-ops.test.ts new file mode 100644 index 00000000..6d06269e --- /dev/null +++ b/web/__tests__/scripts/stripe-limited-preview-ops.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { + buildStripePreviewPreflight, + parseStripeOpsMode, +} from "../../scripts/stripe-limited-preview-ops"; + +describe("Stripe limited-preview operations", () => { + it("only permits read-only preflight and monitor modes", () => { + expect(parseStripeOpsMode([])).toBe("preflight"); + expect(parseStripeOpsMode(["monitor"])).toBe("monitor"); + expect(() => parseStripeOpsMode(["monitor", "--apply"])).toThrow( + "read-only" + ); + expect(() => parseStripeOpsMode(["resolve"])).toThrow("read-only"); + }); + + it("reports every production activation gate without exposing values", () => { + const preflight = buildStripePreviewPreflight({ + DATABASE_URL: "postgres://configured", + STRIPE_SECRET_KEY: "sk_live_secret", + STRIPE_WEBHOOK_SECRET: "whsec_secret", + AGENTVOUCH_STRIPE_CHECKOUT_ENABLED: "true", + NEXT_PUBLIC_STRIPE_CHECKOUT_ENABLED: "true", + VERCEL_ENV: "production", + }); + + expect(preflight.checkoutEnabled).toBe(false); + expect(preflight.blockers).toContain( + "production edge rate limit is not acknowledged by AGENTVOUCH_STRIPE_EDGE_RATE_LIMIT_READY" + ); + expect(JSON.stringify(preflight)).not.toContain("sk_live_secret"); + + const ready = buildStripePreviewPreflight({ + DATABASE_URL: "postgres://configured", + STRIPE_SECRET_KEY: "sk_live_secret", + STRIPE_WEBHOOK_SECRET: "whsec_secret", + AGENTVOUCH_STRIPE_CHECKOUT_ENABLED: "true", + NEXT_PUBLIC_STRIPE_CHECKOUT_ENABLED: "true", + AGENTVOUCH_STRIPE_EDGE_RATE_LIMIT_READY: "true", + VERCEL_ENV: "production", + }); + expect(ready.checkoutEnabled).toBe(true); + expect(ready.blockers).toEqual([]); + }); +}); diff --git a/web/app/api/stripe/checkout/route.ts b/web/app/api/stripe/checkout/route.ts index e20ef467..e3c67da8 100644 --- a/web/app/api/stripe/checkout/route.ts +++ b/web/app/api/stripe/checkout/route.ts @@ -1,12 +1,12 @@ // Tier 1 Stripe checkout — PROTOTYPE. See docs/STRIPE_FEASIBILITY.md. // Creates a Stripe Checkout Session for a paid skill's listed price. No-ops -// with 501 unless Stripe is configured. +// with 501 unless Stripe is configured and checkout is explicitly activated. import { NextRequest, NextResponse } from "next/server"; import { initializeDatabase, sql } from "@/lib/db"; import { STRIPE_MIN_CHARGE_USD_CENTS, createCheckoutSession, - isStripeEnabled, + getStripeCheckoutActivation, usdcMicrosToUsdCents, } from "@/lib/stripe"; import { @@ -18,6 +18,10 @@ import { import { getErrorMessage } from "@/lib/errors"; import { hasUsdcPurchaseEntitlement } from "@/lib/usdcPurchases"; import { hasOnChainPurchase } from "@/lib/x402"; +import { checkRateLimit, clientIpFromRequest } from "@/lib/rateLimit"; + +const STRIPE_CHECKOUT_IP_LIMIT = { limit: 20, windowMs: 15 * 60_000 }; +const STRIPE_CHECKOUT_WALLET_LIMIT = { limit: 5, windowMs: 10 * 60_000 }; type SkillPriceRow = { id: string; @@ -36,11 +40,16 @@ function resolveBaseUrl(req: NextRequest): string { } export async function POST(req: NextRequest) { - if (!isStripeEnabled()) { + const activation = getStripeCheckoutActivation(); + if (!activation.enabled) { + const reason = !activation.stripeConfigured + ? "Configure STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET." + : !activation.serverFlagEnabled + ? "Set AGENTVOUCH_STRIPE_CHECKOUT_ENABLED=true." + : "Install the production edge rate limit, then set AGENTVOUCH_STRIPE_EDGE_RATE_LIMIT_READY=true."; return NextResponse.json( { - error: - "Stripe payments are not enabled. Configure STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET before accepting checkout payments.", + error: `Stripe checkout is not enabled. ${reason}`, }, { status: 501 } ); @@ -76,6 +85,37 @@ export async function POST(req: NextRequest) { ); } + // Defense in depth only: this limiter is per runtime instance. Production + // activation separately requires an operator acknowledgement that a Vercel + // Firewall/WAF rule protects this route at the edge. + const ipLimit = checkRateLimit( + `stripe-checkout:ip:${clientIpFromRequest(req)}`, + STRIPE_CHECKOUT_IP_LIMIT + ); + if (!ipLimit.ok) { + return NextResponse.json( + { error: "Too many card checkout attempts. Try again shortly." }, + { + status: 429, + headers: { "Retry-After": String(ipLimit.retryAfterSeconds) }, + } + ); + } + + const walletLimit = checkRateLimit( + `stripe-checkout:wallet:${verification.pubkey}`, + STRIPE_CHECKOUT_WALLET_LIMIT + ); + if (!walletLimit.ok) { + return NextResponse.json( + { error: "Too many card checkout attempts. Try again shortly." }, + { + status: 429, + headers: { "Retry-After": String(walletLimit.retryAfterSeconds) }, + } + ); + } + try { await initializeDatabase(); diff --git a/web/app/api/stripe/webhook/route.ts b/web/app/api/stripe/webhook/route.ts index 798a5f7c..bac83347 100644 --- a/web/app/api/stripe/webhook/route.ts +++ b/web/app/api/stripe/webhook/route.ts @@ -25,6 +25,10 @@ import { verifyAndParseWebhook, } from "@/lib/stripe"; import { getErrorMessage } from "@/lib/errors"; +import { + recordStripeWebhookOutcome, + type RecordStripeWebhookOutcomeInput, +} from "@/lib/stripeReconciliation"; type SessionObject = { id: string; @@ -60,15 +64,58 @@ function metadataString( return value || null; } +async function recordOutcomeOrRetry( + input: RecordStripeWebhookOutcomeInput, + response: NextResponse +): Promise { + try { + await recordStripeWebhookOutcome(input); + return response; + } catch (error) { + // A durable audit/review record is part of processing. Returning 500 keeps + // Stripe retries active instead of silently losing an operator action. + return NextResponse.json( + { + error: `Failed to persist Stripe webhook outcome: ${getErrorMessage( + error + )}`, + }, + { status: 500 } + ); + } +} + // Terminal ack for events this endpoint will never be able to fulfill. -// Logged so paid-but-not-entitled cases stay visible to reconciliation. -function ackUnprocessable(sessionId: string | undefined, reason: string) { +// Persisted before ACK so paid-but-not-entitled cases survive log retention. +async function ackUnprocessable(input: { + eventId: string; + eventType: string; + objectId?: string | null; + paymentRef?: string | null; + skillDbId?: string | null; + buyerKey?: string | null; + reason: string; + needsReview?: boolean; +}) { console.error( - `Stripe webhook unprocessable (session ${ - sessionId ?? "unknown" - }): ${reason}` + `Stripe webhook unprocessable (object ${input.objectId ?? "unknown"}): ${ + input.reason + }` + ); + return recordOutcomeOrRetry( + { + eventId: input.eventId, + eventType: input.eventType, + objectId: input.objectId, + paymentRef: input.paymentRef, + skillDbId: input.skillDbId, + buyerKey: input.buyerKey, + outcome: input.needsReview === false ? "ignored" : "needs-review", + reason: input.reason, + needsReview: input.needsReview !== false, + }, + NextResponse.json({ received: true, ignored: input.reason }) ); - return NextResponse.json({ received: true, ignored: reason }); } export async function POST(req: NextRequest) { @@ -105,10 +152,12 @@ export async function POST(req: NextRequest) { const object = event.data.object as RefundOrDisputeObject; const paymentRef = stripePaymentRef(object.payment_intent); if (!paymentRef) { - return ackUnprocessable( - object.id, - `${event.type} without payment_intent` - ); + return await ackUnprocessable({ + eventId: event.id, + eventType: event.type, + objectId: object.id, + reason: `${event.type} without payment_intent`, + }); } if (event.type === "charge.refunded" && object.refunded !== true) { console.warn( @@ -116,7 +165,19 @@ export async function POST(req: NextRequest) { object.amount_refunded ?? "?" }); entitlement kept — reconcile manually.` ); - return NextResponse.json({ received: true, ignored: "partial refund" }); + return await recordOutcomeOrRetry( + { + eventId: event.id, + eventType: event.type, + objectId: object.id, + paymentRef, + outcome: "needs-review", + reason: "partial refund", + needsReview: true, + details: { amountRefunded: object.amount_refunded ?? null }, + }, + NextResponse.json({ received: true, ignored: "partial refund" }) + ); } try { @@ -137,7 +198,22 @@ export async function POST(req: NextRequest) { `Stripe ${reason} on ${paymentRef}: no live entitlement matched (already revoked, superseded by a newer purchase, or never minted) — reconcile manually.` ); } - return NextResponse.json({ received: true, revoked: revoked.length }); + return await recordOutcomeOrRetry( + { + eventId: event.id, + eventType: event.type, + objectId: object.id, + paymentRef, + outcome: revoked.length > 0 ? "revoked" : "needs-review", + reason: + revoked.length > 0 + ? reason + : `${reason}: no live entitlement matched`, + needsReview: revoked.length === 0, + details: { revokedEntitlements: revoked.length }, + }, + NextResponse.json({ received: true, revoked: revoked.length }) + ); } catch (error) { return NextResponse.json( { error: getErrorMessage(error) }, @@ -168,19 +244,33 @@ export async function POST(req: NextRequest) { if (paymentFlow !== STRIPE_PAYMENT_FLOW) { // Not a session this app created (e.g. dashboard-created session on a // shared Stripe account). Never fulfillable here. - return ackUnprocessable( - session.id, - "payment_flow is not an AgentVouch Stripe payment" - ); + return await ackUnprocessable({ + eventId: event.id, + eventType: event.type, + objectId: session.id, + reason: "payment_flow is not an AgentVouch Stripe payment", + }); } if (!skillDbId || !buyerPubkey || !checkoutPriceMicros) { - return ackUnprocessable( - session.id, - "missing skill_db_id, buyer_pubkey, or price_usdc_micros metadata" - ); + return await ackUnprocessable({ + eventId: event.id, + eventType: event.type, + objectId: session.id, + skillDbId, + buyerKey: buyerPubkey, + reason: + "missing skill_db_id, buyer_pubkey, or price_usdc_micros metadata", + }); } if (session.mode !== "payment") { - return ackUnprocessable(session.id, "session is not a one-time payment"); + return await ackUnprocessable({ + eventId: event.id, + eventType: event.type, + objectId: session.id, + skillDbId, + buyerKey: buyerPubkey, + reason: "session is not a one-time payment", + }); } if (session.payment_status !== "paid") { // Normal ordering for delayed payment methods: `completed` arrives with @@ -192,26 +282,51 @@ export async function POST(req: NextRequest) { }); } if ((session.currency ?? "").toLowerCase() !== "usd") { - return ackUnprocessable(session.id, "session currency is not USD"); + return await ackUnprocessable({ + eventId: event.id, + eventType: event.type, + objectId: session.id, + skillDbId, + buyerKey: buyerPubkey, + reason: "session currency is not USD", + }); } let micros: bigint; try { micros = BigInt(checkoutPriceMicros); } catch { - return ackUnprocessable(session.id, "price_usdc_micros is invalid"); + return await ackUnprocessable({ + eventId: event.id, + eventType: event.type, + objectId: session.id, + skillDbId, + buyerKey: buyerPubkey, + reason: "price_usdc_micros is invalid", + }); } if (micros <= 0n) { - return ackUnprocessable(session.id, "price_usdc_micros must be positive"); + return await ackUnprocessable({ + eventId: event.id, + eventType: event.type, + objectId: session.id, + skillDbId, + buyerKey: buyerPubkey, + reason: "price_usdc_micros must be positive", + }); } if ( typeof session.amount_total !== "number" || session.amount_total !== usdcMicrosToUsdCents(micros) ) { - return ackUnprocessable( - session.id, - "charged amount does not match checkout metadata price" - ); + return await ackUnprocessable({ + eventId: event.id, + eventType: event.type, + objectId: session.id, + skillDbId, + buyerKey: buyerPubkey, + reason: "charged amount does not match checkout metadata price", + }); } try { @@ -231,23 +346,38 @@ export async function POST(req: NextRequest) { LIMIT 1 `; if (!rows[0]) { - return ackUnprocessable(session.id, "skill no longer exists"); + return await ackUnprocessable({ + eventId: event.id, + eventType: event.type, + objectId: session.id, + skillDbId, + buyerKey: buyerPubkey, + reason: "skill no longer exists", + }); } if (rows[0].evm_listing_id) { - return ackUnprocessable( - session.id, - "card checkout is unavailable for Base protocol listings" - ); + return await ackUnprocessable({ + eventId: event.id, + eventType: event.type, + objectId: session.id, + skillDbId, + buyerKey: buyerPubkey, + reason: "card checkout is unavailable for Base protocol listings", + }); } // Stripe payment reference + wallet buyer identity from checkout metadata. // `payment_tx_signature` is UNIQUE, giving us idempotency on retries. const paymentRef = stripePaymentRef(session.payment_intent); if (!paymentRef) { - return ackUnprocessable( - session.id, - "paid session without payment_intent" - ); + return await ackUnprocessable({ + eventId: event.id, + eventType: event.type, + objectId: session.id, + skillDbId, + buyerKey: buyerPubkey, + reason: "paid session without payment_intent", + }); } // The entitlement upsert is last-receipt-wins: letting a Stripe receipt @@ -264,10 +394,16 @@ export async function POST(req: NextRequest) { entitlement.revoked && (await hasUsdcPurchaseReceiptForPaymentRef(paymentRef)) ) { - return ackUnprocessable( - session.id, - "payment was refunded or disputed; entitlement stays revoked" - ); + return await ackUnprocessable({ + eventId: event.id, + eventType: event.type, + objectId: session.id, + paymentRef, + skillDbId, + buyerKey: buyerPubkey, + reason: "payment was refunded or disputed; entitlement stays revoked", + needsReview: false, + }); } const recorded = await recordRevocableUsdcPurchaseReceipt({ @@ -280,17 +416,39 @@ export async function POST(req: NextRequest) { paymentFlow: STRIPE_PAYMENT_FLOW, }); if (recorded.revoked) { - return ackUnprocessable( - session.id, - "payment was refunded or disputed; entitlement stays revoked" - ); + return await ackUnprocessable({ + eventId: event.id, + eventType: event.type, + objectId: session.id, + paymentRef, + skillDbId, + buyerKey: buyerPubkey, + reason: "payment was refunded or disputed; entitlement stays revoked", + needsReview: false, + }); } - return NextResponse.json({ - received: true, - entitled: buyerPubkey, - alreadyEntitled: entitlement.exists && !entitlement.revoked, - }); + return await recordOutcomeOrRetry( + { + eventId: event.id, + eventType: event.type, + objectId: session.id, + paymentRef, + skillDbId, + buyerKey: buyerPubkey, + outcome: "fulfilled", + reason: "wallet-bound entitlement recorded", + needsReview: false, + details: { + alreadyEntitled: entitlement.exists && !entitlement.revoked, + }, + }, + NextResponse.json({ + received: true, + entitled: buyerPubkey, + alreadyEntitled: entitlement.exists && !entitlement.revoked, + }) + ); } catch (error) { return NextResponse.json( { error: getErrorMessage(error) }, diff --git a/web/lib/stripe.ts b/web/lib/stripe.ts index 355bbaca..d31ad6f6 100644 --- a/web/lib/stripe.ts +++ b/web/lib/stripe.ts @@ -41,6 +41,44 @@ export function isStripeEnabled(): boolean { return Boolean(config?.secretKey && config.webhookSecret); } +export type StripeCheckoutActivation = { + enabled: boolean; + stripeConfigured: boolean; + serverFlagEnabled: boolean; + productionEdgeRateLimitReady: boolean; + production: boolean; +}; + +/** + * Checkout session creation is a separate activation boundary from webhook + * processing. Webhooks must remain live after checkout is disabled so delayed + * payments, refunds, disputes, and retries can still converge. + */ +export function getStripeCheckoutActivation( + env: Readonly> = process.env +): StripeCheckoutActivation { + const stripeConfigured = Boolean( + env.STRIPE_SECRET_KEY?.trim() && env.STRIPE_WEBHOOK_SECRET?.trim() + ); + const serverFlagEnabled = env.AGENTVOUCH_STRIPE_CHECKOUT_ENABLED === "true"; + const production = env.VERCEL_ENV === "production"; + const productionEdgeRateLimitReady = + !production || env.AGENTVOUCH_STRIPE_EDGE_RATE_LIMIT_READY === "true"; + + return { + enabled: + stripeConfigured && serverFlagEnabled && productionEdgeRateLimitReady, + stripeConfigured, + serverFlagEnabled, + productionEdgeRateLimitReady, + production, + }; +} + +export function isStripeCheckoutServerEnabled(): boolean { + return getStripeCheckoutActivation().enabled; +} + export function isStripeCheckoutUiEnabled(): boolean { return process.env.NEXT_PUBLIC_STRIPE_CHECKOUT_ENABLED === "true"; } diff --git a/web/lib/stripeReconciliation.ts b/web/lib/stripeReconciliation.ts new file mode 100644 index 00000000..7654fd5f --- /dev/null +++ b/web/lib/stripeReconciliation.ts @@ -0,0 +1,232 @@ +import { sql } from "@/lib/db"; + +let schemaReady: Promise | null = null; + +export type StripeWebhookOutcome = + | "fulfilled" + | "revoked" + | "ignored" + | "needs-review"; + +export type RecordStripeWebhookOutcomeInput = { + eventId: string; + eventType: string; + objectId?: string | null; + paymentRef?: string | null; + skillDbId?: string | null; + buyerKey?: string | null; + outcome: StripeWebhookOutcome; + reason: string; + needsReview: boolean; + details?: Record; +}; + +export type StripeReconciliationItem = { + eventId: string; + eventType: string; + objectId: string | null; + paymentRef: string | null; + skillDbId: string | null; + buyerKey: string | null; + outcome: StripeWebhookOutcome; + reason: string; + details: Record; + occurrenceCount: number; + firstSeenAt: string; + lastSeenAt: string; +}; + +export type StripeReconciliationAlert = { + eventId: string; + severity: "warning" | "critical"; + message: string; +}; + +export async function ensureStripeReconciliationSchema(): Promise { + if (schemaReady) return schemaReady; + + schemaReady = (async () => { + const db = sql(); + await db` + CREATE TABLE IF NOT EXISTS stripe_webhook_outcomes ( + event_id VARCHAR(128) PRIMARY KEY, + event_type VARCHAR(128) NOT NULL, + object_id VARCHAR(128), + payment_ref VARCHAR(128), + skill_db_id VARCHAR(64), + buyer_key VARCHAR(128), + outcome VARCHAR(32) NOT NULL, + reason TEXT NOT NULL, + needs_review BOOLEAN NOT NULL DEFAULT FALSE, + details JSONB NOT NULL DEFAULT '{}'::jsonb, + occurrence_count INTEGER NOT NULL DEFAULT 1, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ, + resolution_note TEXT + ) + `; + await db` + CREATE INDEX IF NOT EXISTS idx_stripe_webhook_outcomes_open_review + ON stripe_webhook_outcomes(last_seen_at DESC) + WHERE needs_review = TRUE AND resolved_at IS NULL + `; + await db` + CREATE INDEX IF NOT EXISTS idx_stripe_webhook_outcomes_payment_ref + ON stripe_webhook_outcomes(payment_ref) + WHERE payment_ref IS NOT NULL + `; + })().catch((error) => { + schemaReady = null; + throw error; + }); + + return schemaReady; +} + +export async function recordStripeWebhookOutcome( + input: RecordStripeWebhookOutcomeInput +): Promise { + await ensureStripeReconciliationSchema(); + const details = JSON.stringify(input.details ?? {}); + + await sql()` + INSERT INTO stripe_webhook_outcomes ( + event_id, + event_type, + object_id, + payment_ref, + skill_db_id, + buyer_key, + outcome, + reason, + needs_review, + details, + first_seen_at, + last_seen_at + ) + VALUES ( + ${input.eventId.slice(0, 128)}, + ${input.eventType.slice(0, 128)}, + ${input.objectId?.slice(0, 128) ?? null}, + ${input.paymentRef?.slice(0, 128) ?? null}, + ${input.skillDbId?.slice(0, 64) ?? null}, + ${input.buyerKey?.slice(0, 128) ?? null}, + ${input.outcome}, + ${input.reason}, + ${input.needsReview}, + ${details}::jsonb, + NOW(), + NOW() + ) + ON CONFLICT (event_id) + DO UPDATE SET + event_type = EXCLUDED.event_type, + object_id = EXCLUDED.object_id, + payment_ref = EXCLUDED.payment_ref, + skill_db_id = EXCLUDED.skill_db_id, + buyer_key = EXCLUDED.buyer_key, + outcome = EXCLUDED.outcome, + reason = EXCLUDED.reason, + needs_review = EXCLUDED.needs_review, + details = EXCLUDED.details, + occurrence_count = stripe_webhook_outcomes.occurrence_count + 1, + last_seen_at = NOW(), + resolved_at = CASE + WHEN EXCLUDED.needs_review THEN stripe_webhook_outcomes.resolved_at + ELSE NULL + END, + resolution_note = CASE + WHEN EXCLUDED.needs_review THEN stripe_webhook_outcomes.resolution_note + ELSE NULL + END + `; +} + +async function queryOpenStripeReconciliationItems( + limit: number +): Promise { + const safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 500); + const rows = await sql()<{ + event_id: string; + event_type: string; + object_id: string | null; + payment_ref: string | null; + skill_db_id: string | null; + buyer_key: string | null; + outcome: StripeWebhookOutcome; + reason: string; + details: Record | null; + occurrence_count: number; + first_seen_at: string; + last_seen_at: string; + }>` + SELECT + event_id, + event_type, + object_id, + payment_ref, + skill_db_id, + buyer_key, + outcome, + reason, + details, + occurrence_count, + first_seen_at::text, + last_seen_at::text + FROM stripe_webhook_outcomes + WHERE needs_review = TRUE + AND resolved_at IS NULL + ORDER BY last_seen_at DESC + LIMIT ${safeLimit} + `; + + return rows.map((row) => ({ + eventId: row.event_id, + eventType: row.event_type, + objectId: row.object_id, + paymentRef: row.payment_ref, + skillDbId: row.skill_db_id, + buyerKey: row.buyer_key, + outcome: row.outcome, + reason: row.reason, + details: row.details ?? {}, + occurrenceCount: row.occurrence_count, + firstSeenAt: row.first_seen_at, + lastSeenAt: row.last_seen_at, + })); +} + +export async function listOpenStripeReconciliationItems( + limit = 100 +): Promise { + await ensureStripeReconciliationSchema(); + return queryOpenStripeReconciliationItems(limit); +} + +/** Read-only monitor path: deliberately does not run schema bootstrap DDL. */ +export async function listOpenStripeReconciliationItemsReadOnly( + limit = 100 +): Promise { + const rows = await sql()<{ table_name: string | null }>` + SELECT to_regclass('public.stripe_webhook_outcomes')::text AS table_name + `; + if (!rows[0]?.table_name) return []; + return queryOpenStripeReconciliationItems(limit); +} + +export function buildStripeReconciliationAlerts( + items: StripeReconciliationItem[], + nowMs = Date.now(), + criticalAgeMs = 15 * 60_000 +): StripeReconciliationAlert[] { + return items.map((item) => { + const ageMs = Math.max(0, nowMs - Date.parse(item.firstSeenAt)); + const severity = ageMs >= criticalAgeMs ? "critical" : "warning"; + return { + eventId: item.eventId, + severity, + message: `${item.eventType} needs review: ${item.reason}`, + }; + }); +} diff --git a/web/package.json b/web/package.json index e597a331..2eaab612 100644 --- a/web/package.json +++ b/web/package.json @@ -14,6 +14,7 @@ "db:widen-protocol-version": "tsx scripts/widen-protocol-version-columns.ts", "x402:bridge-poc": "tsx scripts/x402-bridge-poc.ts", "base:a1:ops": "tsx scripts/base-paid-report-e2e-smoke.ts", + "stripe:ops": "tsx scripts/stripe-limited-preview-ops.ts", "test": "vitest run", "test:watch": "vitest" }, diff --git a/web/scripts/stripe-limited-preview-ops.ts b/web/scripts/stripe-limited-preview-ops.ts new file mode 100644 index 00000000..f8685016 --- /dev/null +++ b/web/scripts/stripe-limited-preview-ops.ts @@ -0,0 +1,125 @@ +import { pathToFileURL } from "node:url"; +import { getStripeCheckoutActivation } from "../lib/stripe"; +import { + buildStripeReconciliationAlerts, + listOpenStripeReconciliationItemsReadOnly, +} from "../lib/stripeReconciliation"; + +export type StripeOpsMode = "preflight" | "monitor"; + +export function parseStripeOpsMode(args: string[]): StripeOpsMode { + if (args.some((arg) => /apply|write|resolve|secret|key/i.test(arg))) { + throw new Error( + "Stripe operations command is read-only; apply/write/resolve and secret-bearing arguments are disabled" + ); + } + const mode = args[0] ?? "preflight"; + if (mode !== "preflight" && mode !== "monitor") { + throw new Error( + `Unsupported mode ${mode}; only read-only preflight and monitor modes are enabled` + ); + } + if (args.length > 1) { + throw new Error("Stripe operations command accepts exactly one mode"); + } + return mode; +} + +export type StripePreviewPreflight = { + readOnly: true; + checkoutEnabled: boolean; + stripeConfigured: boolean; + serverFlagEnabled: boolean; + uiFlagEnabled: boolean; + production: boolean; + productionEdgeRateLimitReady: boolean; + databaseConfigured: boolean; + blockers: string[]; +}; + +export function buildStripePreviewPreflight( + env: Readonly> = process.env +): StripePreviewPreflight { + const activation = getStripeCheckoutActivation(env); + const uiFlagEnabled = env.NEXT_PUBLIC_STRIPE_CHECKOUT_ENABLED === "true"; + const databaseConfigured = Boolean(env.DATABASE_URL?.trim()); + const blockers: string[] = []; + + if (!databaseConfigured) blockers.push("DATABASE_URL is not configured"); + if (!activation.stripeConfigured) { + blockers.push("Stripe API and webhook secrets are not both configured"); + } + if (!activation.serverFlagEnabled) { + blockers.push("AGENTVOUCH_STRIPE_CHECKOUT_ENABLED is not true"); + } + if (!uiFlagEnabled) { + blockers.push("NEXT_PUBLIC_STRIPE_CHECKOUT_ENABLED is not true"); + } + if (!activation.productionEdgeRateLimitReady) { + blockers.push( + "production edge rate limit is not acknowledged by AGENTVOUCH_STRIPE_EDGE_RATE_LIMIT_READY" + ); + } + + return { + readOnly: true, + checkoutEnabled: activation.enabled, + stripeConfigured: activation.stripeConfigured, + serverFlagEnabled: activation.serverFlagEnabled, + uiFlagEnabled, + production: activation.production, + productionEdgeRateLimitReady: activation.productionEdgeRateLimitReady, + databaseConfigured, + blockers, + }; +} + +export async function runStripeOps( + mode: StripeOpsMode, + env: Readonly> = process.env +): Promise<{ ok: boolean; output: Record }> { + const preflight = buildStripePreviewPreflight(env); + if (mode === "preflight") { + return { + ok: preflight.blockers.length === 0, + output: { mode, ...preflight }, + }; + } + + if (!preflight.databaseConfigured) { + return { + ok: false, + output: { mode, ...preflight, items: [], alerts: [] }, + }; + } + + const items = await listOpenStripeReconciliationItemsReadOnly(); + const alerts = buildStripeReconciliationAlerts(items); + return { + ok: preflight.blockers.length === 0 && alerts.length === 0, + output: { + mode, + ...preflight, + openReviewCount: items.length, + items, + alerts, + }, + }; +} + +async function main(): Promise { + const mode = parseStripeOpsMode(process.argv.slice(2)); + const result = await runStripeOps(mode); + console.log(JSON.stringify(result.output, null, 2)); + if (!result.ok) process.exitCode = 1; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +}