Skip to content

feat(merchant): production-readiness sweep for the portal, checkout cancel and API hardening - #101

Merged
Gift-Stack merged 20 commits into
mainfrom
claude/xend-merchant-production-ready-126c9b
Sep 20, 2026
Merged

Gift-Stack merged 20 commits into
mainfrom
claude/xend-merchant-production-ready-126c9b

Conversation

@Gift-Stack

Copy link
Copy Markdown
Contributor

Closes the code side of the merchant production-readiness backlog in docs/specs/merchant-production-readiness.md. The two exclusions in that spec, mainnet execution and above-limit physical-device acceptance, stay deferred and are not simulated here.

Base is codex/pay-with-xend-merchant-v1, which has not merged to main yet, so this stacks on it and merges the latest review fixes from that branch in.

What changed

Backend (owner-authenticated portal surface)

  • New merchant_audit_log (separate from the operator log) records profile, key, webhook and KYB writes, read back through an owner-scoped /merchant-portal/audit.
  • Dashboard responses now go through a serializer that drops the owner provider id and every server-only column, so a read returns the merchant's own business data and never the credential that authenticates it.
  • API keys can be named, carry creation and last-use metadata, and can be rotated (a successor under the same mode and name, linked to its predecessor, then the old key is revoked).
  • Owner-authenticated webhook management (/merchant-portal/webhooks): create, list, rotate secret, delete, and delivery diagnostics, reusing the existing endpoint service so SSRF validation and the secret lifecycle are identical to the API-key surface.
  • Payments history (/merchant-portal/payments) with cursor pagination, status and reference filters, scoped to the active cluster, plus a detail route with the USDC quote, exchange-rate fields when present, the confirmation reference (settlement signature) and the webhook delivery status for the intent.
  • KYB submit/resubmit records a submitted time and clears a prior rejection reason. Verification is never self-approved.
  • Baseline security headers on every API response (nosniff, referrer-policy, frame DENY, COOP, and HSTS in production). No CSP, so it does not fight the checkout surface's per-merchant frame-ancestors.

Merchant portal (frontend)

  • Real routes with a dependency-free history router: refresh, deep links and back/forward all work, and the global top navigation is fixed in place on every page.
  • New screens for Webhooks and Activity (audit), a paginated and filterable Payments list with a detail view, and API-key naming plus rotation with creation/last-use metadata.
  • API base is configurable (VITE_API_BASE) for split-origin serving; it defaults to same-origin.

Checkout

  • Cancel now says it dismissed this attempt and that the store can be retried, matching the real semantics: it does not terminally cancel the intent, which stays a merchant-side capability.
  • Added a test that the More info disclosure is a native <summary>, so it is keyboard and screen-reader operable.

Docs

  • ADRs 0034 (portal routing), 0035 (owner surface, audit, least-privilege) and 0036 (cancel semantics), a production-serving guide, and a spec update recording what this sweep closed.

Testing

  • Backend: 1033 unit tests pass. Opt-in HTTP + PostgreSQL integration tests (profile, keys, key rotation, webhooks, payments pagination and detail, audit, ownership isolation, least-privilege) pass against a real local PostgreSQL with the new migrations applied.
  • Merchant: 43 component and router tests pass; production build passes; the four new panels were reviewed on desktop and at 375px.
  • Checkout: 72 tests pass and typecheck passes.

Deferred

Mainnet execution and above-limit physical-device acceptance remain the two open gates and are out of scope here.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-09-20T17:25:11.715346Z 1c44766 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 838d83ceff

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +294 to +295
const issued = await this.keys.rotateKey(id, merchant.id);
await this.audit.record({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make key rotation and its audit record atomic

If audit.record() fails after rotateKey() commits, the request returns an error after the old key has already been revoked and the successor stored, but the successor's one-time raw secret never reaches the merchant. This can leave an integration without a usable credential and an unlogged security-sensitive change; execute the rotation and audit insert in one transaction so an audit failure preserves the old key.

Useful? React with 👍 / 👎.

Comment on lines +332 to +333
) : page === "account" ? (
<section className="account-workspace" id="setup">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep account drafts across route changes

When an owner edits the business profile and clicks another navigation item or uses browser history, this route branch unmounts BusinessProfileForm; its local draft disappears and the beforeunload warning does not run for History API navigation. The previous hidden-panel implementation kept the form mounted, so routed navigation should either preserve that behavior or call canLeave() before leaving the account page.

Useful? React with 👍 / 👎.

Comment on lines +148 to +154
const [existing] = await this.db.client
.select()
.from(apiKeys)
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.merchantId, merchantId)))
.limit(1);
if (!existing || existing.revokedAt)
throw new ApiKeyNotFoundError(`api key ${keyId} not found`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize concurrent rotations of the same key

If two rotation requests read the same active key before either revokes it, both pass the revokedAt check, each issues a successor, and the idempotent revocation then lets both requests succeed. The merchant is left with two active replacement credentials instead of one, so selecting/claiming the old key, issuing its successor, and revoking it must be serialized or guarded by an atomic conditional update.

Useful? React with 👍 / 👎.

Comment thread apps/merchant/src/router.ts Outdated
Comment on lines +70 to +72
const paymentDetail = path.match(/^\/payments\/(.+)$/);
if (paymentDetail?.[1])
return { page: "payment", paymentId: decodeURIComponent(paymentDetail[1]) };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle malformed payment path encoding

A malformed deep link such as /payments/% causes decodeURIComponent to throw URIError while the workspace renders, crashing the entire portal instead of resolving to a safe page. Since pathnames are user-controlled and deep links are now supported, catch decoding failures and fall back to the overview, payments list, or a not-found route.

Useful? React with 👍 / 👎.

Comment on lines +168 to +169
.where(eq(merchants.id, merchant.id))
.returning();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent KYB submission from overwriting verification

If an operator verifies the merchant after this request reads the pending/rejected row but before the update executes, the unconditional WHERE id = ... update changes the newly verified merchant back to pending. Because ApiKeyGuard rechecks KYB on every live request, this race immediately disables the merchant's live keys; include the observed non-verified status in the update predicate and return a conflict when it no longer matches.

Useful? React with 👍 / 👎.

Comment thread apps/merchant/src/money.ts Outdated
Comment on lines +17 to +19
export function formatDisplayAmount(currency: string, minor: string): string {
const amount = Number(minor) / 100;
if (!Number.isFinite(amount)) return `${currency} ${minor}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Format minor-unit amounts without floating-point coercion

Payment-intent creation accepts an unbounded decimal digit string, so a valid amount above JavaScript's safe-integer range loses precision when converted with Number; sufficiently large values become Infinity and are then displayed as the unscaled minor-unit string. Payment history and detail can therefore show a different price from the stored intent, so format the amount with BigInt and the currency exponent as the existing checkout formatter does.

Useful? React with 👍 / 👎.

Comment on lines +50 to +56
// 3. An owner-authenticated portal route rejects an unauthenticated request
// rather than leaking data (401, not 200 and not a network/404 error).
try {
const me = await fetch(`${API_URL}/merchant-portal/me`);
check(
"Portal /me rejects an unauthenticated request",
me.status === 401,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exercise the portal API path in the production smoke test

In the preferred same-origin topology, a missing /merchant-portal/* reverse-proxy rule leaves the portal unusable, but this check still passes because it calls API_URL directly rather than the route the browser reaches at MERCHANT_URL. The remaining checks only verify the SPA shell, so a deployment with a working API and static portal but broken integration reports success; test the merchant-origin API path for same-origin deployments, and send an Origin header/check CORS for split-origin deployments.

Useful? React with 👍 / 👎.

Comment on lines +164 to +166
.where(eq(webhookDeliveries.endpointId, id))
.orderBy(desc(webhookDeliveries.createdAt))
.limit(take);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Implement the advertised webhook delivery pagination

Once an endpoint has more than take delivery attempts, this route permanently hides every older attempt because it accepts no cursor and returns no nextCursor; ordering also omits the documented id tie-breaker. That prevents merchants from reaching historical failures during webhook troubleshooting, so implement the stated (createdAt, id) keyset pagination or stop truncating the diagnostic history.

Useful? React with 👍 / 👎.

Base automatically changed from codex/pay-with-xend-merchant-v1 to main September 20, 2026 09:42
…umber portal migration to 0050, keep least-privilege dashboard, adopt inline checkout disclosure and devnet live-key guard

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 990c142c34

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

url: body.url,
eventTypes: body.eventTypes ?? null,
});
await this.audit.record({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make webhook creation and audit recording atomic

If endpoint registration commits but audit.record() fails, this request returns an error before exposing the one-time signing secret, while the active endpoint remains stored and can immediately receive deliveries signed with a secret the merchant never obtained. Reads intentionally omit the secret, so retrying only creates another endpoint; wrap the endpoint insert and audit insert in the existing database transaction boundary so both roll back together.

Useful? React with 👍 / 👎.

Comment thread apps/merchant/src/WebhooksPanel.tsx Outdated
Comment on lines +227 to +231
const data = await client.get<{ deliveries: Delivery[] }>(
`webhooks/${encodeURIComponent(endpoint.id)}/deliveries`,
);
setDeliveries(data.deliveries);
} finally {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle delivery-history request failures

When the delivery request fails because of a network error, expired token, or backend response, the rejection escapes this async handler invoked with void; the finally block only stops the spinner, so the portal displays neither an error nor a retry indication and also produces an unhandled promise rejection. Catch the failure and expose it in the card or panel error state.

Useful? React with 👍 / 👎.

Comment on lines +303 to +306
} catch (error) {
if (error instanceof ApiKeyNotFoundError)
throw new NotFoundException('API key not found');
throw error;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Map key-rotation eligibility failures to conflicts

When a live key is no longer eligible for rotation—for example, the merchant has since been rejected, its settlement destination is incomplete, or the key belongs to an execution cluster now disabled—rotateKey() throws a plain KybNotVerifiedError, SettlementDestinationMissingError, or ExecutionClusterDisabledError. This catch maps only missing keys and rethrows the rest, so Nest returns a 500 for an expected merchant-actionable conflict; map these eligibility errors to the same 409 responses used by key issuance.

Useful? React with 👍 / 👎.

Comment on lines +849 to +851
// The reviewer's note stamped on a rejection, shown to the owner so a
// resubmission can fix the named problem. Null while pending or verified.
kybReviewNote: text('kyb_review_note'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Provide an operator transition for KYB rejection

The new portal depends on kybStatus === 'rejected' and this note to show a rejection reason and enable the resubmission flow, but a repo-wide search finds this field only being serialized or cleared: no controller, service, or ops script can atomically set the rejected status and review note. Consequently the advertised workflow cannot be exercised without direct SQL and owners will never receive an actionable rejection reason through supported operations; add an authenticated operator rejection transition that persists both values.

Useful? React with 👍 / 👎.

Comment on lines +161 to +163
.from(webhookDeliveries)
.where(eq(webhookDeliveries.correlationId, intent.id))
.orderBy(desc(webhookDeliveries.createdAt));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Index webhook deliveries by correlation ID

Every payment-detail request filters the global webhook_deliveries table by correlation_id, but the schema only indexes endpoint, event, and retry-status columns. As delivery history grows, opening any payment detail therefore requires a sequential scan of the entire delivery table and can become slow or time out; add a migration with an index such as (correlation_id, created_at) to support this lookup and ordering.

Useful? React with 👍 / 👎.

…mit races, paginate webhook deliveries, harden money formatting, path decoding, draft-guarded nav and the smoke check

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 66b6ace8b1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const endpoint = await this.endpoints.find(id, {
merchantId: merchant.id,
});
await this.audit.record({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make webhook secret rotation and auditing atomic

If audit.record() fails after rotateSecret() persists the new primary, this request returns an error without revealing that one-time secret. The merchant's known secret remains usable only during the grace window and then webhook verification starts failing; retries can evict the known secondary even sooner. Run the rotation and audit insert in one transaction so an audit failure preserves the existing secret.

Useful? React with 👍 / 👎.

Comment thread scripts/smoke-merchant-production.mjs Outdated
Comment on lines +69 to +70
const sameOrigin = MERCHANT_URL !== API_URL;
if (sameOrigin) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish the documented deployment topologies correctly

The new comparison reverses the documented cases: in a true same-origin deployment API_URL === MERCHANT_URL, yet the script runs a CORS preflight even though docs/merchant-portal-production.md says no CORS entry is needed; with split origins the URLs differ, yet it requires the static merchant host to proxy /merchant-portal/me, which that topology intentionally does not provide. Consequently this smoke test rejects valid deployments instead of checking the correct browser path; select the topology explicitly or invert/rework this branching.

Useful? React with 👍 / 👎.

*/

function subscribe(callback: () => void) {
window.addEventListener("popstate", callback);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve profile drafts during browser history navigation

When the account form is dirty and the owner presses Back or Forward, this popstate callback immediately updates the route and unmounts the form without invoking canLeave(). The newly added go() guard only covers explicit navigation clicks, so the browser-history case from the earlier finding remains; route history transitions through the blocker or keep the form mounted/persist its draft.

Useful? React with 👍 / 👎.

Comment on lines 414 to 417
onClick={() =>
void run(async () => {
await request("destination", {});
await refresh();
setData(await client.post<Dashboard>("kyb/submit"));
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Save or block dirty profile data before KYB submission

If an owner edits the business-profile form and clicks “Submit business details for verification” before pressing Save, this handler posts only kyb/submit; the draft lives inside BusinessProfileForm and is never included. The backend therefore marks the old persisted profile as submitted while the UI keeps showing the unsaved values alongside “Verification in review,” so reviewers receive different details from those the owner believes were submitted. Save the draft first or block submission while the profile is dirty.

Useful? React with 👍 / 👎.

Comment on lines +90 to +93
lt(paymentIntents.createdAt, after.createdAt),
and(
eq(paymentIntents.createdAt, after.createdAt),
lt(paymentIntents.id, after.id),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve database timestamp precision in pagination cursors

PostgreSQL stores created_at with microsecond precision, but cursorRow() materializes it as a JavaScript Date, which retains only milliseconds. If multiple intents are created within one millisecond and the cursor has nonzero microseconds, the next query compares against the truncated instant: older rows from that same millisecond are greater than the truncated cutoff and not equal to it, so they are skipped permanently. Carry the exact database timestamp in the cursor or order and compare at consistently truncated precision; the audit and webhook-delivery pagination use the same vulnerable pattern.

Useful? React with 👍 / 👎.

Comment on lines +61 to +64
setRows((current) =>
reset ? page.payments : [...current, ...page.payments],
);
setCursor(page.nextCursor);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore stale payment responses after filters change

If the owner applies a status or search filter while the initial request or a “Load more” request is still in flight, both requests remain active and whichever resolves last executes these state updates. A slower response for the previous filters can therefore replace or append rows and its cursor under the newly selected filters, producing a mixed or entirely unfiltered result set. Abort superseded requests or associate each response with the active filter/request generation before updating state.

Useful? React with 👍 / 👎.

// leave a revocation that no trail records.
const revoked = await this.db.client.transaction(async (tx) => {
const result = await this.keys.revokeKey(id, tx);
await this.audit.record(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid auditing idempotent revocation retries as new changes

If the first revocation commits but its response is lost, retrying the request calls revokeKey(), which deliberately returns the already-revoked row, and then unconditionally writes another api_key.revoke entry here. No key state changed on the retry, yet the owner-visible security trail shows a second revocation at a new time, making the append-only audit inaccurate. Have the service report whether it claimed the active row and record the action only for the actual transition while retaining idempotent HTTP success.

Useful? React with 👍 / 👎.

… KYB-rejection transition, map rotation eligibility to 409, index webhook deliveries by correlation id, and handle delivery-load errors

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4954d361c0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

throw new ConflictException(
'This profile changed in another session. Reload the latest profile before saving.',
);
await this.audit.record({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Commit profile updates and audit entries together

If audit.record() fails after the preceding profile update commits, this endpoint returns an error even though the new profile and incremented version are already persisted, leaving the sensitive change absent from the promised audit trail; retrying with the form's old expectedVersion then produces a conflict. Run the profile update and audit insert in the same database transaction, as the key and webhook writes already do.

Useful? React with 👍 / 👎.

: []),
),
)
.orderBy(desc(paymentIntents.createdAt), desc(paymentIntents.id))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Index the payment-history pagination order

For a merchant with a large payment history, this common unfiltered query must scan the merchant/cluster rows and sort all of them by (created_at, id) before applying the small page limit. The schema has only the idempotency index (merchant_id, execution_cluster, idempotency_key), which cannot provide this ordering, so every page becomes progressively expensive; add a matching index beginning with (merchant_id, execution_cluster, created_at, id).

Useful? React with 👍 / 👎.

…ile drafts, keyset paginate at full timestamp precision, drop stale payment responses, skip idempotent revoke audit, correct smoke topology

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 023a4c1202

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

db: DbExecutor = this.db.client,
): Promise<{ secret: string; secondaryExpiresAt: Date }> {
const endpoint = await this.find(id, scope);
const endpoint = await this.find(id, scope, db);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize concurrent webhook secret rotations

When two rotation requests for the same endpoint run concurrently, both plain reads can capture the same primary secret before either update locks the row. The second update then overwrites the first request's new primary while restoring only the original secret as secondary, so the first caller receives a one-time secret that is immediately invalid. Lock the endpoint row before deriving the replacement or make the update conditional on the primary value/version read.

Useful? React with 👍 / 👎.

throw new ConflictException(
'Your verification status changed. Reload your account before resubmitting.',
);
await this.audit.record({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Commit KYB submission and its audit record atomically

If this audit insert fails after the preceding merchant update commits, the request returns an error even though a rejection was already cleared and the merchant was moved to pending with a new submission timestamp. The promised append-only trail then omits the actual KYB transition, and retrying records only a later submission time; run the update and audit insert in one database transaction.

Useful? React with 👍 / 👎.

Comment thread apps/merchant/src/WebhooksPanel.tsx Outdated
const [url, setUrl] = useState("");
const [mode, setMode] = useState<"test" | "live">("test");
const [busy, setBusy] = useState(false);
const [secret, setSecret] = useState("");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve one-time webhook secrets across navigation

After endpoint creation or secret rotation succeeds, the only copy returned to the merchant is stored in this panel-local state. Navigating to any other workspace route unmounts WebhooksPanel and permanently discards that value even if it has not been copied; reads intentionally omit it while the endpoint remains active. Keep the reveal state above the routed branch or block navigation until the merchant explicitly dismisses it.

Useful? React with 👍 / 👎.

Comment on lines +219 to +220
.orderBy(desc(webhookDeliveries.createdAt), desc(webhookDeliveries.id))
.limit(take + 1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Index webhook delivery pagination by its full order

For an endpoint with substantial delivery history, this query must fetch and sort all rows matching endpoint_id by (created_at, id) before applying the small page limit. The schema's existing endpoint index contains only endpoint_id, so pagination becomes increasingly expensive as deliveries accumulate; add a composite index beginning with (endpoint_id, created_at, id) to support this ordering.

Useful? React with 👍 / 👎.

…dit entries and index the payment-history and delivery pagination orders

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c7cca1b601

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +287 to +292
await this.audit.record({
merchantId: merchant.id,
actor: merchant.ownerProviderId ?? 'unknown',
action: 'destination.provision',
target: merchant.id,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Audit destination provisioning only when it changes state

When a provisioning response is lost and the owner retries, provisionOrLink() intentionally returns the existing destination with provisioned: false, but this unconditional call still appends another destination.provision entry. The owner-visible append-only trail then reports a fresh sensitive change even though the retry changed nothing; use the returned provisioned flag to record only the actual transition.

Useful? React with 👍 / 👎.

Comment on lines +161 to +163
.from(webhookDeliveries)
.where(eq(webhookDeliveries.correlationId, intent.id))
.orderBy(desc(webhookDeliveries.createdAt));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Paginate payment-detail webhook deliveries

For a payment sent to many endpoints or subjected to repeated manual redeliveries, this query loads every matching delivery into one response because it has no limit or cursor. Since manual redeliveries are explicitly unbounded, opening the payment detail can eventually produce an arbitrarily large database result, JSON payload, and rendered table; paginate or cap this collection as the per-endpoint delivery route does.

Useful? React with 👍 / 👎.

Comment thread apps/merchant/src/main.tsx Outdated
mode,
name: keyName.trim() || undefined,
});
setRawKey(key.raw);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve each raw API key until the owner dismisses it

After issuing or rotating a key, the creation and rotation controls become enabled again while the one-time secret is still displayed. If the owner performs another issuance or rotation before copying the first secret, this assignment replaces the only copy of the first raw key even though that credential remains active and reads cannot recover it; block further key mutations until dismissal or retain each outstanding reveal separately.

Useful? React with 👍 / 👎.

Comment on lines +46 to +50
const data = await client.get<{ endpoints: WebhookEndpoint[] }>(
"webhooks",
);
setEndpoints(data.endpoints);
setStatus("ready");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore stale webhook endpoint list responses

The endpoint creation form is usable while the initial load() is still in flight, and every load writes its response without a request generation check. If that initial GET captured the pre-creation list but arrives after the post-create refresh, it overwrites the newer list and makes the successfully created endpoint disappear from the UI until another reload; discard superseded responses or abort the prior request.

Useful? React with 👍 / 👎.

Comment on lines +169 to +173
@Post('kyb/submit')
async submitKyb(@Headers('authorization') authorization?: string) {
const merchant = await this.owner.owned(authorization);
if (merchant.kybStatus === 'verified')
throw new ConflictException('Your business is already verified.');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require business details before accepting KYB submission

A newly registered merchant has an empty businessProfile, and the profile schema also permits every field to remain an empty string, but this endpoint moves either case into the review queue without validating any required legal, contact, or address details. The untouched registration page therefore lets an owner submit immediately and shows “Verification in review” even though reviewers received no business information; reject submission until the minimum review fields are populated.

Useful? React with 👍 / 👎.

… one-time secrets in the workspace shell so navigation cannot discard them

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 586b421349

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const [updated] = await db
.update(webhookEndpoints)
.set({ enabled: false, updatedAt: new Date() })
.where(eq(webhookEndpoints.id, endpoint.id))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Claim webhook deletion before recording it

When two portal deletion requests reach disable() before either transaction commits, both can pass find() while the endpoint is enabled, and this unconditional update lets both requests succeed. MerchantPortalWebhooksController.remove() then appends a webhook.delete audit entry in each transaction even though only one state transition occurred, leaving the owner-visible activity log inaccurate. Make the update conditional on enabled = true (or lock the row) and report whether this request claimed the transition before auditing it.

Useful? React with 👍 / 👎.

.update(merchants)
.set({
kybStatus: 'pending',
kybSubmittedAt: now,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind verification to the submitted profile version

After this timestamp marks a profile as submitted, POST /merchant-portal/profile still permits pending merchants to change every KYB field without clearing the submission or requiring another review, and markKybVerified() does not check which profile version the operator reviewed. An owner can therefore change the legal name or address after the reviewer reads the submission but before verification is stamped, leaving the altered profile verified and enabling live-key issuance without those details having been reviewed. Persist the submitted profile version or snapshot and make verification conditional on it, or invalidate the submission whenever review-relevant fields change.

Useful? React with 👍 / 👎.

// never obtained (reads omit it, so a retry would only orphan another).
const { endpoint, secret } = await this.db.client.transaction(
async (tx) => {
const created = await this.endpoints.register(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve webhook hosts before opening a transaction

For every portal endpoint creation, this transaction calls WebhookEndpointService.register(), which performs an unbounded dns.lookup() before executing its insert. A slow or deliberately non-responsive hostname therefore holds a database connection and an open transaction for the resolver timeout; with the default pool of ten connections, one authenticated merchant can submit enough such requests to starve unrelated API traffic. Run the URL/DNS validation before acquiring the transaction and keep only the endpoint and audit inserts inside it.

Useful? React with 👍 / 👎.

Comment thread apps/merchant/src/WebhooksPanel.tsx Outdated
try {
const created = await client.post<WebhookEndpoint & { secret: string }>(
"webhooks",
{ url, mode },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Map devnet webhooks to test delivery mode

When the deployment uses devnet, execution payments have database mode live but paymentDeliveryMode() intentionally routes them to test endpoints so they cannot be represented as production traffic. This portal form nevertheless offers and submits a raw live endpoint mode without considering the dashboard cluster; a merchant configuring a “Live” endpoint for their devnet execution key creates an enabled endpoint that the dispatcher will never select, so no payment webhooks arrive. Map devnet endpoint creation to the test delivery mode, or disable/relabel the live choice using the active cluster.

Useful? React with 👍 / 👎.

…y real destination provisioning, cap payment-detail deliveries, guard against overwriting an unshown secret and stale webhook list responses

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0caffd92b8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +309 to +315
if (result.provisioned)
await this.audit.record({
merchantId: merchant.id,
actor: merchant.ownerProviderId ?? 'unknown',
action: 'destination.provision',
target: merchant.id,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist destination state and audit atomically

When provisioning persists the settlement_accounts row but this audit insert subsequently fails, the endpoint returns an error even though the destination is active. A retry then makes SettlementProvisioningService.provisionOrLink() return provisioned: false, so this branch is skipped and the owner-visible trail permanently omits the actual provisioning. Persist the settlement row and audit entry in one database transaction after external provisioning, or use a durable outbox so the audit cannot be lost.

Useful? React with 👍 / 👎.

Comment on lines +28 to +33
function describe(entry: AuditEntry): string {
const parts: string[] = [];
if (entry.metadata)
for (const [key, value] of Object.entries(entry.metadata))
parts.push(`${key}: ${value}`);
return parts.join(" · ");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Show the affected object in audit entries

When a merchant has multiple API keys or webhook endpoints, actions such as api_key.revoke, webhook.rotate_secret, and webhook.delete have no metadata, and this formatter ignores the target field already returned by the API. Their activity rows therefore contain only an action label and timestamp, so the owner cannot determine which credential or endpoint changed. Render the target or record and display safe identifying metadata such as the key fingerprint or endpoint URL.

Useful? React with 👍 / 👎.

Comment on lines +142 to +144
.where(eq(paymentAttempts.intentId, intent.id))
.orderBy(desc(paymentAttempts.createdAt))
.limit(1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Index the latest-attempt lookup by intent

For a terminal Payment, this lookup cannot use payment_attempts_live_idx because that index contains only authorized and settling rows, while the query has no status predicate; the only other attempt index is on status. Opening a payment detail therefore requires scanning the global attempts table and sorting the matching rows as history grows. Add an index beginning with (intent_id, created_at) (and the ordering tie-breaker if one is added to the query).

Useful? React with 👍 / 👎.

… claim webhook deletion before auditing, resolve webhook URLs outside the transaction, and route devnet webhooks to test mode

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 188b5a97fc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// read and this update. Only move to pending from the exact status
// observed, so a submission never reverts a merchant that just
// became verified (which would disable their live keys).
eq(merchants.kybStatus, merchant.kybStatus),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match KYB submission on the profile version

When another session saves the profile after owned() returns but before this update executes, the status can remain pending, so this predicate still succeeds and stores the stale merchant.profileVersion as the submitted version alongside the newly saved profile. The portal then reports “Verification in review,” but the operator verification path refuses it because the submitted and current versions differ; include profileVersion in this conditional update so the request returns a conflict instead.

Useful? React with 👍 / 👎.

Comment thread apps/merchant/src/main.tsx Outdated
: current,
);
setRawKey("");
setSecretReveal(null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve unrelated one-time secrets during key revocation

The workspace-level reveal can now contain a webhook signing secret as well as an API key, and it deliberately survives navigation. If an owner creates or rotates a webhook, navigates to Developers, and revokes any API key before copying the webhook secret, this unconditional clear discards the only copy while the endpoint remains active. Leave the reveal intact unless the revoked credential is itself the revealed secret, or disable revocation while any reveal is pending.

Useful? React with 👍 / 👎.

throw new Error('--mark-kyb-rejected requires --reason "<note>"');
}
const res = await client.query(
`UPDATE merchants SET kyb_status = 'rejected', kyb_review_note = $2, kyb_verified_at = NULL, updated_at = now() WHERE id = $1 RETURNING id`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject only an active matching KYB submission

The new rejection command updates any existing merchant without requiring an active submission whose version still matches the reviewed profile. A delayed review result can therefore reject a profile the owner has since edited, and accidentally targeting an already verified merchant clears verification and causes all of its live keys to start returning 403. Apply the same submitted-version/current-version guard used by the verification command, and restrict the transition to the expected review state.

Useful? React with 👍 / 👎.

Comment thread apps/merchant/src/WebhooksPanel.tsx Outdated
Comment on lines +146 to +149
<p>
Xend posts a signed event to your endpoint when a Payment is
confirmed. Verify the signature before fulfilling an order; a browser
redirect alone is not proof of payment.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Tell merchants to require a successful webhook event

Endpoints created by this form omit eventTypes, so the dispatcher sends payment.failed and payment.expired as well as payment.succeeded. This copy instead says the webhook arrives when a Payment is confirmed and only instructs the merchant to verify its signature, but failed and expired events are also validly signed; an integration following that guidance can fulfill an unpaid order. Require consumers to check for payment.succeeded explicitly, or default the portal endpoint to that event type.

Useful? React with 👍 / 👎.

Comment on lines +42 to +45
check(
"API is not cacheable",
(health.headers.get("cache-control") ?? "").includes("no-store"),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify all advertised production security headers

The production guide says this smoke test verifies the API's baseline security headers, which it defines to include X-Frame-Options, Cross-Origin-Opener-Policy, and production HSTS, but the script finishes its header checks after nosniff and Referrer-Policy and proceeds directly to cacheability. A proxy stripping those headers—or a backend accidentally running without production HSTS—therefore still passes the deployment check; add assertions for the remaining advertised headers.

Useful? React with 👍 / 👎.

… payment attempt, and identify every audit row by its object
…, keep a pending secret through revocation, gate fulfillment copy on payment.succeeded, and assert all advertised security headers

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 907ad05f85

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +121 to +122
`UPDATE merchants SET kyb_status = 'verified', kyb_verified_at = now(), updated_at = now()
WHERE id = $1 AND kyb_submitted_version IS NOT NULL AND kyb_submitted_version = profile_version RETURNING id`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require a pending submission before marking KYB verified

If a profile is rejected and a delayed or accidentally repeated --mark-kyb-verified command runs afterward, this predicate still matches because rejection leaves kyb_submitted_version equal to profile_version. The command then overwrites the rejection with verified, re-enabling live-key access despite there being no active pending review; restrict the update to kyb_status = 'pending' so only the currently reviewed submission can be approved.

Useful? React with 👍 / 👎.

Comment thread apps/merchant/vercel.json
"$schema": "https://openapi.vercel.sh/vercel.json",
"buildCommand": "npm run build",
"outputDirectory": "dist",
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent the merchant portal from being framed

When the portal is deployed using this new Vercel configuration, the static app receives no X-Frame-Options or CSP frame-ancestors policy; the middleware added in this change protects only backend responses, not the document containing the authenticated key, webhook, and KYB controls. A malicious origin can therefore frame a signed-in portal and overlay UI to induce destructive actions such as key revocation or webhook deletion, so configure an anti-framing response header for the portal routes as well.

Useful? React with 👍 / 👎.

Comment on lines +47 to +49
const allowPrivate =
this.config.get<boolean>('WEBHOOK_ALLOW_PRIVATE_URLS') ?? false;
await assertPublicHttpsUrl(url, { allowPrivate });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refuse the private-webhook escape hatch in production

When a production deployment accidentally carries WEBHOOK_ALLOW_PRIVATE_URLS=true, this code passes it through unchanged and skips all resolved-address checks, even though the new production guide states that production keeps the SSRF guard enabled regardless. The configuration schema also permits true for every NODE_ENV, so an authenticated merchant can register an HTTPS endpoint resolving to an internal or loopback service and make the delivery worker request it; force this option off or fail startup outside development/test.

Useful? React with 👍 / 👎.

…ed so a repeated approval cannot overwrite a rejection

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c09b7d1f73

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +165 to +172
const [claimed] = await db
.update(apiKeys)
.set({ revokedAt: new Date() })
.where(
and(
eq(apiKeys.id, keyId),
eq(apiKeys.merchantId, merchantId),
isNull(apiKeys.revokedAt),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make key rotation recoverable after response loss

If the rotation transaction commits but its HTTP response is lost, the old key is already revoked and the successor's raw secret is unrecoverable. Retrying with the same key ID then misses the revokedAt IS NULL predicate and returns not found, leaving a live integration without its old credential or the replacement credential. Add an idempotency mechanism or a rotation protocol that preserves access across ambiguous responses.

Useful? React with 👍 / 👎.

Comment thread apps/checkout/src/screens/Result.tsx Outdated
Comment on lines +72 to +74
title: 'Checkout canceled',
detail:
'This attempt was canceled. Head back to the store to try again.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish terminal cancellations from dismissed attempts

When Checkout opens an intent whose backend status is already canceled, App treats that status as non-payable and renders this same branch. In that case the merchant permanently canceled the intent, so telling the consumer that only this attempt was canceled and to try the same order again is false; pass the cancellation source to Result or use copy that does not promise the intent remains payable.

Useful? React with 👍 / 👎.

Comment thread apps/merchant/src/PaymentDetail.tsx Outdated
Comment on lines +193 to +196
{payment.webhookDeliveries.length === 0 ? (
<p>
No webhook deliveries recorded. A webhook fires only when a
Payment is confirmed on-chain.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Describe failed and expired webhook events in the empty state

For a failed or expired Payment with no recorded delivery, this empty state says webhooks fire only after on-chain confirmation, but the dispatcher also emits payment.failed and payment.expired. This can lead merchants diagnosing a missing failure notification to believe no webhook was expected; describe all configured outcome events here instead.

Useful? React with 👍 / 👎.

Comment on lines +13 to +15
| 'webhook.create'
| 'webhook.rotate_secret'
| 'webhook.delete'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Audit webhook mutations made through the API-key surface

The new audit action set covers webhook creation, rotation, and deletion, but the existing API-key-authenticated /v1/webhook_endpoints controller still calls WebhookEndpointService directly without recording any of them. A merchant—or a compromised integration key—can therefore change delivery destinations or signing secrets while the owner-visible Activity log remains empty; wire those mutations into the same transactional audit path or narrow the audit trail's stated coverage.

Useful? React with 👍 / 👎.

Comment on lines 92 to +97
case '--mark-kyb-verified':
args.markKybVerified = next();
break;
case '--mark-kyb-rejected':
args.markKybRejected = next();
break;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject contradictory KYB decision flags

If an operator or automation invocation supplies both --mark-kyb-verified and --mark-kyb-rejected, the parser accepts both and main() silently executes the verification branch first. A malformed review command can therefore approve a merchant when rejection was also requested, enabling live-key eligibility; validate these decision flags as mutually exclusive before connecting to the database.

Useful? React with 👍 / 👎.

Comment on lines +149 to +152
if (!updated)
throw new WebhookSecretRotationConflictError(
`endpoint ${id} secret rotated concurrently; retry`,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Map internal webhook rotation conflicts to 409

When two internal webhook-secret rotation requests race, the losing update now throws this conflict error, but WebhookAdminController.mapServiceError() only handles unsafe URLs and not-found errors. Nest therefore returns a 500 to the internal caller instead of the conflict response used by both merchant-facing controllers, which can cause generic server-error retries to rotate the winner's newly issued secret again; map this error to HTTP 409 on the internal route as well.

Useful? React with 👍 / 👎.

Comment on lines +97 to +102
profileVersion: body.expectedVersion + 1,
// Editing the profile withdraws any pending submission: the reviewer
// must see the changed details, so verification cannot be stamped
// against the version they already read.
kybSubmittedAt: null,
kybSubmittedVersion: null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve KYB submissions on unchanged profile saves

While a merchant is under review, opening the business-profile editor and pressing Save without changing any value still reaches this update, increments the profile version, and clears both submission fields. The UI permits such an unchanged save, so the merchant silently falls out of the reviewable state even though no reviewed detail changed; treat identical payloads as a no-op or clear the submission only when review-relevant data actually differs.

Useful? React with 👍 / 👎.

…inguish a merchant-canceled intent from a dismissed attempt, and describe all outcome events in the delivery empty state

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 975580f145

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +142 to +146
.where(
and(
eq(webhookEndpoints.id, id),
eq(webhookEndpoints.secretPrimary, endpoint.secretPrimary),
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep secret rotation conditional on an enabled endpoint

When secret rotation races with deletion and the delete commits after find() but before this update, the predicate still matches the disabled row because it checks only the ID and old secret. The rotation then returns a new one-time secret and records a webhook.rotate_secret audit entry for an endpoint that has already been deleted and can no longer receive deliveries. Include enabled = true in the conditional update so the losing rotation reports a conflict instead.

Useful? React with 👍 / 👎.

Comment on lines +93 to +98
entries: page.map((row) => ({
id: row.id,
action: row.action,
target: row.target,
metadata: row.metadata,
at: row.at.toISOString(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return a safe actor label in the merchant audit feed

The new API-key webhook mutation path now stores actor: api_key:<id> specifically so the owner-visible Activity log can show who made the change, but this response mapper drops row.actor. Consequently an endpoint redirected or secret rotated through an integration key is indistinguishable from the owner performing it in the portal, undermining incident investigation. Return a safe representation such as the key ID/fingerprint or “You,” without exposing the raw provider identity.

Useful? React with 👍 / 👎.

Comment on lines +101 to +104
const onPopState = () => {
const wasAccount = currentPathRef.current === pagePath("account");
const now = window.location.pathname;
if (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize the account path before guarding browser history

When the account page is opened as /account/, which routeFor() explicitly accepts by stripping trailing slashes, currentPathRef retains the raw /account/ value and this equality check reports that the user was not on the account page. Pressing Back with a dirty business-profile draft therefore bypasses canLeave() and unmounts the form without confirmation; determine the previous page through routeFor() or normalize the stored pathname before comparing it.

Useful? React with 👍 / 👎.

Comment thread apps/merchant/vercel.json
"$schema": "https://openapi.vercel.sh/vercel.json",
"buildCommand": "npm run build",
"outputDirectory": "dist",
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route API requests before the SPA fallback

When this Vercel configuration hosts the portal with VITE_API_BASE left empty—the documented default for the preferred same-origin topology—the catch-all also rewrites /merchant-portal/* requests to index.html, because no preceding rule forwards them to the backend. Dashboard GETs and all mutations then receive HTML with status 200 instead of API JSON, leaving the deployed portal unusable; add ordered API proxy rewrites before the SPA fallback or require a split-origin API base for this deployment.

Useful? React with 👍 / 👎.

…point, show a safe actor label in the audit feed, and normalize the account path in the history guard
@Gift-Stack

Gift-Stack commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor Author

@codex review

@Gift-Stack
Gift-Stack merged commit 075dde5 into main Sep 20, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant