fix(merchant): separate contact OTP from user login, add KYB workflow…#21
Conversation
…soumen0818#11) - Replace signInWithOtp merchant email flow with relayer Admin API endpoints POST /merchants/send-contact-otp and POST /merchants/verify-contact-otp so the wallet owner's Supabase auth session is never replaced by the merchant contact email. - Add KYB columns to merchants table: verification_status (pending/approved/ rejected), verified_contact_email, contact_email_verified, submitted_at, reviewed_at, rejection_reason. - Add merchant_contact_verifications audit table to track OTP send/verify events without touching auth sessions. - Update get_own_merchant_by_wallet RPC to return new KYB columns. - Add RLS policies, indexes, and updated_at trigger for the new table. - MerchantRegistrationScreen: create draft merchant row (is_active=false) before sending OTP so merchantId can be passed to the relayer. On Register, update the draft row instead of re-inserting (avoids UNIQUE wallet_address constraint violation). - Merchant type and registerAsMerchant: include all KYB fields; auto-approve in pilot mode (EXPO_PUBLIC_PILOT_MODE=true), leave pending in production. - auth.ts: sendMerchantContactOtp and verifyMerchantContactOtp call the relayer with the current session bearer token. No signInWithOtp calls. - MerchantDashboardScreen: show pending/approved/rejected status banner. - MerchantQRGeneratorScreen and MerchantGlobalQRScreen: block QR generation for unapproved merchants with an alert and navigation back. - Pilot mode: PILOT_MODE=true on the relayer auto-approves on OTP verify, so existing pilot UX is unchanged. Production defaults to pending review. Fixes soumen0818#11
|
@JoesWalker Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
📝 WalkthroughWalkthroughAdds a merchant KYB (Know Your Business) verification workflow. The database schema gains verification status fields and a ChangesMerchant KYB & Contact OTP Flow
Sequence Diagram(s)sequenceDiagram
participant App as MerchantRegistrationScreen
participant AuthSvc as auth.ts
participant Relayer as relayer-service
participant SupabaseAdmin
participant DB as Supabase DB
App->>DB: registerAsMerchant (inactive draft, status=pending)
App->>AuthSvc: sendMerchantContactOtp(merchantId, email)
AuthSvc->>Relayer: POST /merchants/send-contact-otp (Bearer token)
Relayer->>DB: verify merchant ownership
Relayer->>SupabaseAdmin: POST /auth/v1/otp {email}
Relayer->>DB: upsert merchant_contact_verifications
Relayer-->>AuthSvc: {success}
AuthSvc-->>App: {success}
App->>AuthSvc: verifyMerchantContactOtp(merchantId, email, token)
AuthSvc->>Relayer: POST /merchants/verify-contact-otp (Bearer token)
Relayer->>SupabaseAdmin: POST /auth/v1/verify {email, token}
Relayer->>DB: update merchants.verification_status (approved if PILOT_MODE)
Relayer->>DB: patch merchant_contact_verifications
Relayer-->>AuthSvc: {success, verificationStatus}
AuthSvc-->>App: {success, verificationStatus}
App->>DB: UPDATE merchants SET is_active=true (activate draft)
App->>App: syncMerchantContract → navigate MerchantDashboard
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 9
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
App/src/screens/MerchantRegistrationScreen.tsx (1)
80-85: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winDon't drop the draft merchant id when the email changes.
Once the first OTP send creates a draft row, clearing
pendingMerchantIdhere turns the next "Send code" into anotherregisterAsMerchant()insert.App/src/services/merchant.ts:212-280does a plainmerchantsinsert, so correcting the email after the first send will hit the uniquewallet_addressconstraint instead of reusing the existing draft.Suggested fix
const handleEmailChange = (text: string) => { setEmail(text); if (emailVerified) { setEmailVerified(false); } - setPendingMerchantId(''); setEmailOTP(''); setEmailOTPError(''); };🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@App/src/screens/MerchantRegistrationScreen.tsx` around lines 80 - 85, The email change handler in MerchantRegistrationScreen is incorrectly clearing the draft merchant id, which causes later OTP sends to create a new draft instead of updating the existing one. Update handleEmailChange so it only resets the email/verification state and preserves pendingMerchantId, and keep the resend flow in sync with the existing draft created by registerAsMerchant in merchant.ts.
🧹 Nitpick comments (1)
App/src/services/auth.ts (1)
331-347: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick winRetire or relabel the legacy merchant-registration OTP helper.
Line 332 still documents
sendEmailOTPas “for merchant registration”, but it callssupabase.auth.signInWithOtp, the session-changing flow this PR is replacing. Remove it if unused, or rename/comment it as login-only so merchant registration keeps usingsendMerchantContactOtp.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@App/src/services/auth.ts` around lines 331 - 347, The legacy sendEmailOTP helper is mislabeled as merchant-registration OTP even though it uses supabase.auth.signInWithOtp, which is the login flow. Update sendEmailOTP in auth.ts to either remove it if nothing references it or relabel/rename its comment and purpose to login-only, and ensure merchant registration continues to use sendMerchantContactOtp instead.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@App/src/screens/MerchantDashboardScreen.tsx`:
- Around line 206-238: The KYB banner only shows review state, but the
QR-readiness copy/actions in MerchantDashboardScreen still rely on
contractSynced alone. Update the readiness logic in MerchantDashboardScreen so
any “ready/active” messaging and QR-related actions are gated on
verificationStatus === 'approved' in addition to contractSynced, and keep
pending/rejected merchants in the blocked state even if the contract is synced.
Reference the existing verificationStatus banner conditions and the
contractSynced-based readiness checks to make the change consistently.
In `@App/src/screens/MerchantRegistrationScreen.tsx`:
- Around line 164-193: The OTP preflight path in MerchantRegistrationScreen is
incorrectly calling registerAsMerchant, which performs full submission, contract
registration, caching, and merchantRegistered side effects instead of creating a
draft. Replace this with a draft-specific helper for the merchant row creation
branch that only inserts/updates the minimal pending record needed to obtain
merchantId, and keep registerAsMerchant and syncMerchantContract reserved for
the final registration flow after OTP verification/approval. Ensure the new
helper is used in the merchant draft branch and that pending state updates still
happen without marking the device as fully registered.
In `@App/src/services/merchant.ts`:
- Around line 227-239: The merchant creation flow in the insert block currently
defaults to approved when EXPO_PUBLIC_PILOT_MODE is unset, which can promote
draft merchants too early. Update the verificationStatus logic in merchant.ts to
fail closed: only auto-approve when pilot mode is explicitly enabled, otherwise
keep new merchants pending. Then make sure the downstream contract
sync/cache/event section in this flow only runs after both approval and contact
verification are true, including the path triggered by handleSendEmailOTP so
draft records stay pending until OTP verification completes.
In `@App/supabase_schema.sql`:
- Line 61: The contact verification table currently allows unscoped OTP audit
rows because merchant_id is nullable. Update the schema definition for the
contact verification row/OTP flow to make merchant_id required and keep the
foreign key to merchants(id), then ensure any insert/create path that writes
these rows always supplies a merchant_id so every verification record is tied to
a merchant.
- Around line 177-183: The idempotent merchants migration adds
verification_status but does not apply the allowed-value CHECK constraint, so
existing deployments can accept invalid statuses. Update the ALTER TABLE
migration for merchants to add the same verification_status constraint used in
CREATE TABLE, and make it safe to run repeatedly by referencing the merchants
verification_status column and its status validation consistently.
- Around line 574-592: The merchant_contact_verifications policies currently
allow authenticated users to insert and update their own rows, which exposes OTP
audit fields to direct client writes. Update the policy set in
merchant_contact_verifications so clients remain read-only by keeping only the
SELECT policy for auth.uid()-owned rows, and remove or restrict the
INSERT/UPDATE policies so only the service role/relayer path can write
verified_at and is_verified state. Also adjust the GRANT on
merchant_contact_verifications to match the new read-only client access, using
the existing policy names as the place to locate the change.
In `@relayer-service/server.js`:
- Around line 266-315: The /merchants/send-contact-otp handler currently sends
OTPs without any server-side cooldown, so add a throttle check before the
Supabase Auth request. In the app.post route, use fetchMerchantForOwner plus
merchant_contact_verifications.otp_sent_at to enforce a per-(authUserId,
merchantId, contactEmail) cooldown, and reject requests that are too soon with a
429-style response. After a successful send, continue updating the record via
upsertMerchantContactVerification so the timestamp is persisted for future
checks.
- Around line 1186-1189: The auto-approval path in the verification update logic
leaves reviewed_at unset when newVerificationStatus becomes approved. Update the
merchant patching flow around the newVerificationStatus block to stamp
reviewed_at at the same time as the pilot auto-approval timestamp, alongside
submitted_at, so the approval metadata is complete.
- Around line 285-299: The OTP request in server.js is hardcoded with
create_user: false, which causes first-time merchant contact emails to fail with
a signup-not-allowed error. Update the auth/v1/otp flow in the surrounding
adminRes fetch block so it can create the Supabase auth identity for new
contactEmail values, either by setting create_user: true or switching to the
appropriate server-side OTP path. Keep the existing request structure in the
fetch call, but ensure the chosen behavior supports first-time inboxes while
preserving the current contactEmail-based flow.
---
Outside diff comments:
In `@App/src/screens/MerchantRegistrationScreen.tsx`:
- Around line 80-85: The email change handler in MerchantRegistrationScreen is
incorrectly clearing the draft merchant id, which causes later OTP sends to
create a new draft instead of updating the existing one. Update
handleEmailChange so it only resets the email/verification state and preserves
pendingMerchantId, and keep the resend flow in sync with the existing draft
created by registerAsMerchant in merchant.ts.
---
Nitpick comments:
In `@App/src/services/auth.ts`:
- Around line 331-347: The legacy sendEmailOTP helper is mislabeled as
merchant-registration OTP even though it uses supabase.auth.signInWithOtp, which
is the login flow. Update sendEmailOTP in auth.ts to either remove it if nothing
references it or relabel/rename its comment and purpose to login-only, and
ensure merchant registration continues to use sendMerchantContactOtp instead.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 914cc4c9-f517-4e1e-85a2-231893958b60
📒 Files selected for processing (8)
App/src/screens/MerchantDashboardScreen.tsxApp/src/screens/MerchantGlobalQRScreen.tsxApp/src/screens/MerchantQRGeneratorScreen.tsxApp/src/screens/MerchantRegistrationScreen.tsxApp/src/services/auth.tsApp/src/services/merchant.tsApp/supabase_schema.sqlrelayer-service/server.js
| {/* KYB / verification status banner */} | ||
| {verificationStatus === 'pending' && ( | ||
| <View style={[styles.kybBanner, styles.kybPending]}> | ||
| <Ionicons name="time-outline" size={18} color={COLORS.warning} /> | ||
| <View style={styles.kybBannerText}> | ||
| <Text style={[styles.kybBannerTitle, { color: COLORS.warning }]}>Verification under review</Text> | ||
| <Text style={styles.kybBannerSubtitle}> | ||
| Your merchant account is being reviewed. QR payments will be enabled once approved. | ||
| </Text> | ||
| </View> | ||
| </View> | ||
| )} | ||
| {verificationStatus === 'rejected' && ( | ||
| <View style={[styles.kybBanner, styles.kybRejected]}> | ||
| <Ionicons name="close-circle-outline" size={18} color={COLORS.error} /> | ||
| <View style={styles.kybBannerText}> | ||
| <Text style={[styles.kybBannerTitle, { color: COLORS.error }]}>Verification rejected</Text> | ||
| <Text style={styles.kybBannerSubtitle}> | ||
| {rejectionReason || 'Your application was not approved. Please contact support.'} | ||
| </Text> | ||
| </View> | ||
| </View> | ||
| )} | ||
| {verificationStatus === 'approved' && ( | ||
| <View style={[styles.kybBanner, styles.kybApproved]}> | ||
| <Ionicons name="shield-checkmark-outline" size={18} color={COLORS.success} /> | ||
| <View style={styles.kybBannerText}> | ||
| <Text style={[styles.kybBannerTitle, { color: COLORS.success }]}>Account approved</Text> | ||
| <Text style={styles.kybBannerSubtitle}>Your merchant account is active and ready to accept payments.</Text> | ||
| </View> | ||
| </View> | ||
| )} | ||
|
|
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
The new KYB banner still leaves pending merchants looking QR-ready.
The banner says review is pending/rejected, but the screen below still drives merchant/QR readiness from contractSynced only. With the current registration flow, a non-approved merchant can already have contractSynced === true, so this screen can still present them as ready even though QR access is blocked elsewhere. Gate the readiness copy/actions off verificationStatus === 'approved' too.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@App/src/screens/MerchantDashboardScreen.tsx` around lines 206 - 238, The KYB
banner only shows review state, but the QR-readiness copy/actions in
MerchantDashboardScreen still rely on contractSynced alone. Update the readiness
logic in MerchantDashboardScreen so any “ready/active” messaging and QR-related
actions are gated on verificationStatus === 'approved' in addition to
contractSynced, and keep pending/rejected merchants in the blocked state even if
the contract is synced. Reference the existing verificationStatus banner
conditions and the contractSynced-based readiness checks to make the change
consistently.
| if (!merchantId) { | ||
| // Create a minimal draft merchant row so we can pass merchantId to the | ||
| // relayer. The row will be completed (logo, address, etc.) when the user | ||
| // taps "Register" after OTP verification. We mark it inactive until done. | ||
| const finalCategory = category === 'other' ? customCategory : category; | ||
| const normalizedPhone = normalizeMerchantPhoneInput(phoneNumber); | ||
|
|
||
| const draftResult = await registerAsMerchant({ | ||
| business_name: businessName.trim(), | ||
| wallet_address: walletAddress, | ||
| owner_name: ownerName.trim(), | ||
| email: normalizedEmail, | ||
| phone_number: normalizedPhone || undefined, | ||
| business_address: businessAddress.trim() || undefined, | ||
| business_registration_number: businessRegistrationNumber.trim() || undefined, | ||
| description: description.trim() || undefined, | ||
| category: finalCategory || undefined, | ||
| logo_url: 'default-merchant-logo', | ||
| is_active: false, // inactive until fully confirmed | ||
| }); | ||
|
|
||
| if (!draftResult.success || !draftResult.merchantId) { | ||
| setEmailOTPLoading(false); | ||
| AlertManager.alert('Error', draftResult.error || 'Could not create merchant draft. Please try again.'); | ||
| return; | ||
| } | ||
|
|
||
| merchantId = draftResult.merchantId; | ||
| setPendingMerchantId(merchantId); | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Use a draft-specific create path instead of registerAsMerchant here.
registerAsMerchant in App/src/services/merchant.ts:212-280 does far more than create a row: it sets submitted_at/verification_status, calls registerContractMerchant, caches local merchant state, and emits merchantRegistered. Using it for the OTP preflight means tapping "Send code" can submit an incomplete merchant, mark the device as merchant-capable before OTP succeeds, and perform one on-chain registration; the draft flow then calls syncMerchantContract again on Lines 356-357. This needs a draft-only insert/update helper that skips submission and contract-sync side effects until final registration/approval.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@App/src/screens/MerchantRegistrationScreen.tsx` around lines 164 - 193, The
OTP preflight path in MerchantRegistrationScreen is incorrectly calling
registerAsMerchant, which performs full submission, contract registration,
caching, and merchantRegistered side effects instead of creating a draft.
Replace this with a draft-specific helper for the merchant row creation branch
that only inserts/updates the minimal pending record needed to obtain
merchantId, and keep registerAsMerchant and syncMerchantContract reserved for
the final registration flow after OTP verification/approval. Ensure the new
helper is used in the merchant draft branch and that pending state updates still
happen without marking the device as fully registered.
| const isPilotMode = process.env.EXPO_PUBLIC_PILOT_MODE !== 'false'; | ||
| // In pilot mode, registration is auto-approved. In production, leave as pending | ||
| // until an admin reviews the KYB submission. | ||
| const verificationStatus = isPilotMode ? 'approved' : 'pending'; | ||
|
|
||
| const { data, error } = await supabase | ||
| .from('merchants') | ||
| .insert({ | ||
| ...merchant, | ||
| auth_user_id: authUserId, | ||
| cpay_id: cpayId, // Save C-Pay ID to database | ||
| cpay_id: cpayId, | ||
| verification_status: verificationStatus, | ||
| submitted_at: new Date().toISOString(), |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
Fail closed and keep draft merchants pending until OTP verification.
EXPO_PUBLIC_PILOT_MODE !== 'false' auto-approves when the env var is missing, and handleSendEmailOTP creates an inactive draft through this function before OTP verification. That draft can be stored as approved and proceed into contract sync/cache/event below. Force pilot mode to be explicit, keep drafts pending, and only sync contracts after approval plus contact verification.
Proposed fix
- const isPilotMode = process.env.EXPO_PUBLIC_PILOT_MODE !== 'false';
+ const isPilotMode = process.env.EXPO_PUBLIC_PILOT_MODE === 'true';
+ const isDraft = merchant.is_active === false;
// In pilot mode, registration is auto-approved. In production, leave as pending
// until an admin reviews the KYB submission.
- const verificationStatus = isPilotMode ? 'approved' : 'pending';
+ const verificationStatus = !isDraft && isPilotMode ? 'approved' : 'pending';
const { data, error } = await supabase
.from('merchants')
.insert({
...merchant,
auth_user_id: authUserId,
cpay_id: cpayId,
verification_status: verificationStatus,
- submitted_at: new Date().toISOString(),
+ submitted_at: isDraft ? null : new Date().toISOString(),
})
.select()
.single();
+
+ if (isDraft) {
+ return {
+ success: true,
+ merchantId: data.id,
+ contractSynced: false,
+ contractStatus: 'draft',
+ verificationStatus: data.verification_status || verificationStatus,
+ };
+ }Then gate the existing contract sync block with:
+ const canSyncContract =
+ data.verification_status === 'approved' &&
+ data.contact_email_verified === true;
+
- try {
+ if (canSyncContract) {
+ try {
const contractResult = await registerContractMerchant(data.id, merchant.wallet_address);
contractSynced = true;
contractStatus = contractResult.contractStatus || contractResult.status || 'synced';
- } catch (contractSyncError: any) {
- contractError = contractSyncError?.message || 'Contract merchant registration failed';
- console.warn('Merchant saved but contract sync failed:', contractError);
+ } catch (contractSyncError: any) {
+ contractError = contractSyncError?.message || 'Contract merchant registration failed';
+ console.warn('Merchant saved but contract sync failed:', contractError);
+ }
+ } else {
+ contractStatus = 'pending_kyb';
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@App/src/services/merchant.ts` around lines 227 - 239, The merchant creation
flow in the insert block currently defaults to approved when
EXPO_PUBLIC_PILOT_MODE is unset, which can promote draft merchants too early.
Update the verificationStatus logic in merchant.ts to fail closed: only
auto-approve when pilot mode is explicitly enabled, otherwise keep new merchants
pending. Then make sure the downstream contract sync/cache/event section in this
flow only runs after both approval and contact verification are true, including
the path triggered by handleSendEmailOTP so draft records stay pending until OTP
verification completes.
| CREATE TABLE IF NOT EXISTS merchant_contact_verifications ( | ||
| id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), | ||
| auth_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, | ||
| merchant_id UUID REFERENCES merchants(id) ON DELETE CASCADE, |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Require merchant_id for contact verification rows.
The OTP flow is merchant-scoped, but nullable merchant_id allows unscoped audit rows that cannot be reliably tied back to a merchant.
Proposed fix
- merchant_id UUID REFERENCES merchants(id) ON DELETE CASCADE,
+ merchant_id UUID NOT NULL REFERENCES merchants(id) ON DELETE CASCADE,📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| merchant_id UUID REFERENCES merchants(id) ON DELETE CASCADE, | |
| merchant_id UUID NOT NULL REFERENCES merchants(id) ON DELETE CASCADE, |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@App/supabase_schema.sql` at line 61, The contact verification table currently
allows unscoped OTP audit rows because merchant_id is nullable. Update the
schema definition for the contact verification row/OTP flow to make merchant_id
required and keep the foreign key to merchants(id), then ensure any
insert/create path that writes these rows always supplies a merchant_id so every
verification record is tied to a merchant.
| -- KYB / contact verification columns (idempotent for existing projects) | ||
| ALTER TABLE merchants ADD COLUMN IF NOT EXISTS verification_status TEXT NOT NULL DEFAULT 'pending'; | ||
| ALTER TABLE merchants ADD COLUMN IF NOT EXISTS verified_contact_email TEXT; | ||
| ALTER TABLE merchants ADD COLUMN IF NOT EXISTS contact_email_verified BOOLEAN NOT NULL DEFAULT FALSE; | ||
| ALTER TABLE merchants ADD COLUMN IF NOT EXISTS submitted_at TIMESTAMPTZ; | ||
| ALTER TABLE merchants ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMPTZ; | ||
| ALTER TABLE merchants ADD COLUMN IF NOT EXISTS rejection_reason TEXT; |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Apply the status CHECK constraint in the idempotent migration too.
New installs get the verification_status constraint from CREATE TABLE, but existing deployments only get the column from ALTER TABLE, without the allowed-value constraint.
Proposed fix
ALTER TABLE merchants ADD COLUMN IF NOT EXISTS verification_status TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE merchants ADD COLUMN IF NOT EXISTS verified_contact_email TEXT;
ALTER TABLE merchants ADD COLUMN IF NOT EXISTS contact_email_verified BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE merchants ADD COLUMN IF NOT EXISTS submitted_at TIMESTAMPTZ;
ALTER TABLE merchants ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMPTZ;
ALTER TABLE merchants ADD COLUMN IF NOT EXISTS rejection_reason TEXT;
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1
+ FROM pg_constraint
+ WHERE conname = 'merchants_verification_status_check'
+ ) THEN
+ ALTER TABLE merchants
+ ADD CONSTRAINT merchants_verification_status_check
+ CHECK (verification_status IN ('pending', 'approved', 'rejected'));
+ END IF;
+END $$;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| -- KYB / contact verification columns (idempotent for existing projects) | |
| ALTER TABLE merchants ADD COLUMN IF NOT EXISTS verification_status TEXT NOT NULL DEFAULT 'pending'; | |
| ALTER TABLE merchants ADD COLUMN IF NOT EXISTS verified_contact_email TEXT; | |
| ALTER TABLE merchants ADD COLUMN IF NOT EXISTS contact_email_verified BOOLEAN NOT NULL DEFAULT FALSE; | |
| ALTER TABLE merchants ADD COLUMN IF NOT EXISTS submitted_at TIMESTAMPTZ; | |
| ALTER TABLE merchants ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMPTZ; | |
| ALTER TABLE merchants ADD COLUMN IF NOT EXISTS rejection_reason TEXT; | |
| -- KYB / contact verification columns (idempotent for existing projects) | |
| ALTER TABLE merchants ADD COLUMN IF NOT EXISTS verification_status TEXT NOT NULL DEFAULT 'pending'; | |
| ALTER TABLE merchants ADD COLUMN IF NOT EXISTS verified_contact_email TEXT; | |
| ALTER TABLE merchants ADD COLUMN IF NOT EXISTS contact_email_verified BOOLEAN NOT NULL DEFAULT FALSE; | |
| ALTER TABLE merchants ADD COLUMN IF NOT EXISTS submitted_at TIMESTAMPTZ; | |
| ALTER TABLE merchants ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMPTZ; | |
| ALTER TABLE merchants ADD COLUMN IF NOT EXISTS rejection_reason TEXT; | |
| DO $$ | |
| BEGIN | |
| IF NOT EXISTS ( | |
| SELECT 1 | |
| FROM pg_constraint | |
| WHERE conname = 'merchants_verification_status_check' | |
| ) THEN | |
| ALTER TABLE merchants | |
| ADD CONSTRAINT merchants_verification_status_check | |
| CHECK (verification_status IN ('pending', 'approved', 'rejected')); | |
| END IF; | |
| END $$; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@App/supabase_schema.sql` around lines 177 - 183, The idempotent merchants
migration adds verification_status but does not apply the allowed-value CHECK
constraint, so existing deployments can accept invalid statuses. Update the
ALTER TABLE migration for merchants to add the same verification_status
constraint used in CREATE TABLE, and make it safe to run repeatedly by
referencing the merchants verification_status column and its status validation
consistently.
| -- merchant_contact_verifications: wallet owner can read/write their own rows. | ||
| -- The relayer uses the service-role key to write verified_at/is_verified. | ||
| DROP POLICY IF EXISTS "merchant_contact_verifications_select_own" ON merchant_contact_verifications; | ||
| CREATE POLICY "merchant_contact_verifications_select_own" ON merchant_contact_verifications | ||
| FOR SELECT | ||
| USING (auth.uid() IS NOT NULL AND auth_user_id = auth.uid()); | ||
|
|
||
| DROP POLICY IF EXISTS "merchant_contact_verifications_insert_own" ON merchant_contact_verifications; | ||
| CREATE POLICY "merchant_contact_verifications_insert_own" ON merchant_contact_verifications | ||
| FOR INSERT | ||
| WITH CHECK (auth.uid() IS NOT NULL AND auth_user_id = auth.uid()); | ||
|
|
||
| DROP POLICY IF EXISTS "merchant_contact_verifications_update_own" ON merchant_contact_verifications; | ||
| CREATE POLICY "merchant_contact_verifications_update_own" ON merchant_contact_verifications | ||
| FOR UPDATE | ||
| USING (auth.uid() IS NOT NULL AND auth_user_id = auth.uid()) | ||
| WITH CHECK (auth.uid() IS NOT NULL AND auth_user_id = auth.uid()); | ||
|
|
||
| GRANT SELECT, INSERT, UPDATE ON merchant_contact_verifications TO authenticated; |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Do not let clients write OTP audit/verification rows directly.
The app now uses relayer endpoints for OTP, but these policies still allow any authenticated owner to insert or update their own is_verified / verified_at rows directly. Keep client access read-only and let the service role write audit state.
Proposed fix
-- merchant_contact_verifications: wallet owner can read/write their own rows.
-- The relayer uses the service-role key to write verified_at/is_verified.
DROP POLICY IF EXISTS "merchant_contact_verifications_select_own" ON merchant_contact_verifications;
CREATE POLICY "merchant_contact_verifications_select_own" ON merchant_contact_verifications
FOR SELECT
USING (auth.uid() IS NOT NULL AND auth_user_id = auth.uid());
DROP POLICY IF EXISTS "merchant_contact_verifications_insert_own" ON merchant_contact_verifications;
-CREATE POLICY "merchant_contact_verifications_insert_own" ON merchant_contact_verifications
-FOR INSERT
-WITH CHECK (auth.uid() IS NOT NULL AND auth_user_id = auth.uid());
DROP POLICY IF EXISTS "merchant_contact_verifications_update_own" ON merchant_contact_verifications;
-CREATE POLICY "merchant_contact_verifications_update_own" ON merchant_contact_verifications
-FOR UPDATE
-USING (auth.uid() IS NOT NULL AND auth_user_id = auth.uid())
-WITH CHECK (auth.uid() IS NOT NULL AND auth_user_id = auth.uid());
-GRANT SELECT, INSERT, UPDATE ON merchant_contact_verifications TO authenticated;
+REVOKE INSERT, UPDATE ON merchant_contact_verifications FROM authenticated;
+GRANT SELECT ON merchant_contact_verifications TO authenticated;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| -- merchant_contact_verifications: wallet owner can read/write their own rows. | |
| -- The relayer uses the service-role key to write verified_at/is_verified. | |
| DROP POLICY IF EXISTS "merchant_contact_verifications_select_own" ON merchant_contact_verifications; | |
| CREATE POLICY "merchant_contact_verifications_select_own" ON merchant_contact_verifications | |
| FOR SELECT | |
| USING (auth.uid() IS NOT NULL AND auth_user_id = auth.uid()); | |
| DROP POLICY IF EXISTS "merchant_contact_verifications_insert_own" ON merchant_contact_verifications; | |
| CREATE POLICY "merchant_contact_verifications_insert_own" ON merchant_contact_verifications | |
| FOR INSERT | |
| WITH CHECK (auth.uid() IS NOT NULL AND auth_user_id = auth.uid()); | |
| DROP POLICY IF EXISTS "merchant_contact_verifications_update_own" ON merchant_contact_verifications; | |
| CREATE POLICY "merchant_contact_verifications_update_own" ON merchant_contact_verifications | |
| FOR UPDATE | |
| USING (auth.uid() IS NOT NULL AND auth_user_id = auth.uid()) | |
| WITH CHECK (auth.uid() IS NOT NULL AND auth_user_id = auth.uid()); | |
| GRANT SELECT, INSERT, UPDATE ON merchant_contact_verifications TO authenticated; | |
| -- merchant_contact_verifications: wallet owner can read/write their own rows. | |
| -- The relayer uses the service-role key to write verified_at/is_verified. | |
| DROP POLICY IF EXISTS "merchant_contact_verifications_select_own" ON merchant_contact_verifications; | |
| CREATE POLICY "merchant_contact_verifications_select_own" ON merchant_contact_verifications | |
| FOR SELECT | |
| USING (auth.uid() IS NOT NULL AND auth_user_id = auth.uid()); | |
| DROP POLICY IF EXISTS "merchant_contact_verifications_insert_own" ON merchant_contact_verifications; | |
| DROP POLICY IF EXISTS "merchant_contact_verifications_update_own" ON merchant_contact_verifications; | |
| REVOKE INSERT, UPDATE ON merchant_contact_verifications FROM authenticated; | |
| GRANT SELECT ON merchant_contact_verifications TO authenticated; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@App/supabase_schema.sql` around lines 574 - 592, The
merchant_contact_verifications policies currently allow authenticated users to
insert and update their own rows, which exposes OTP audit fields to direct
client writes. Update the policy set in merchant_contact_verifications so
clients remain read-only by keeping only the SELECT policy for auth.uid()-owned
rows, and remove or restrict the INSERT/UPDATE policies so only the service
role/relayer path can write verified_at and is_verified state. Also adjust the
GRANT on merchant_contact_verifications to match the new read-only client
access, using the existing policy names as the place to locate the change.
| app.post('/merchants/send-contact-otp', requireAuthenticatedUser, async (req, res) => { | ||
| assertSupabasePersistenceConfigured('POST /merchants/send-contact-otp'); | ||
|
|
||
| const merchantId = assertNonEmptyString(req.body.merchantId, 'merchantId'); | ||
| const contactEmail = assertNonEmptyString(req.body.contactEmail, 'contactEmail').toLowerCase().trim(); | ||
|
|
||
| if (!isValidEmail(contactEmail)) { | ||
| return res.status(400).json({ error: 'Invalid contactEmail', code: 'INVALID_EMAIL' }); | ||
| } | ||
|
|
||
| // Verify the authenticated user actually owns this merchant record. | ||
| const authUserId = req.auth?.sub; | ||
| const merchant = await fetchMerchantForOwner(merchantId, authUserId); | ||
| if (!merchant) { | ||
| return res.status(403).json({ error: 'Merchant not found or access denied', code: 'MERCHANT_ACCESS_DENIED' }); | ||
| } | ||
|
|
||
| // Use the Admin API to generate a one-time link / send OTP without opening a | ||
| // session on this server-side call. | ||
| const adminRes = await fetch( | ||
| `${config.supabaseUrl.replace(/\/$/, '')}/auth/v1/otp`, | ||
| { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| apikey: config.supabaseServiceRoleKey, | ||
| Authorization: `Bearer ${config.supabaseServiceRoleKey}`, | ||
| }, | ||
| body: JSON.stringify({ | ||
| email: contactEmail, | ||
| create_user: false, | ||
| // data is ignored for OTP but passing options keeps schema happy | ||
| options: { shouldCreateUser: false }, | ||
| }), | ||
| } | ||
| ); | ||
|
|
||
| if (!adminRes.ok) { | ||
| const body = await adminRes.json().catch(() => ({})); | ||
| const msg = body?.msg || body?.message || body?.error_description || 'Failed to send OTP'; | ||
| return res.status(502).json({ error: msg, code: 'OTP_SEND_FAILED' }); | ||
| } | ||
|
|
||
| // Record that an OTP was requested so we can audit later. | ||
| await upsertMerchantContactVerification({ | ||
| authUserId, | ||
| merchantId, | ||
| contactEmail, | ||
| }); | ||
|
|
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Add a server-side OTP send throttle.
This relayer endpoint can be called directly by any authenticated merchant owner and does not use the app’s local OTP rate limiter. Enforce a cooldown per (authUserId, merchantId, contactEmail) using merchant_contact_verifications.otp_sent_at before calling Supabase Auth.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@relayer-service/server.js` around lines 266 - 315, The
/merchants/send-contact-otp handler currently sends OTPs without any server-side
cooldown, so add a throttle check before the Supabase Auth request. In the
app.post route, use fetchMerchantForOwner plus
merchant_contact_verifications.otp_sent_at to enforce a per-(authUserId,
merchantId, contactEmail) cooldown, and reject requests that are too soon with a
429-style response. After a successful send, continue updating the record via
upsertMerchantContactVerification so the timestamp is persisted for future
checks.
| const adminRes = await fetch( | ||
| `${config.supabaseUrl.replace(/\/$/, '')}/auth/v1/otp`, | ||
| { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| apikey: config.supabaseServiceRoleKey, | ||
| Authorization: `Bearer ${config.supabaseServiceRoleKey}`, | ||
| }, | ||
| body: JSON.stringify({ | ||
| email: contactEmail, | ||
| create_user: false, | ||
| // data is ignored for OTP but passing options keeps schema happy | ||
| options: { shouldCreateUser: false }, | ||
| }), |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the relevant file and surrounding logic
git ls-files relayer-service/server.js
wc -l relayer-service/server.js
sed -n '240,340p' relayer-service/server.js
# Find related OTP/auth usage in the repo
rg -n "create_user|shouldCreateUser|/auth/v1/otp|verify-contact-otp|send-contact-otp|verification_status|reviewed_at" relayer-service -S
# Check for any local docs or comments about Supabase OTP behavior
rg -n "Supabase|OTP|auth/v1/otp|create_user" -S README.md relayer-service .github docs 2>/dev/null || trueRepository: soumen0818/C-Pay
Length of output: 18821
🌐 Web query:
Supabase Auth /auth/v1/otp create_user false non-existing email send OTP semantics
💡 Result:
In Supabase Auth, when calling the /auth/v1/otp endpoint with the create_user parameter set to false, the system intentionally prevents the automatic creation of a new user account if the provided email address or phone number does not already exist in the database [1][2]. The semantics of this configuration are as follows: 1. Verification of Existence: The service first checks whether the provided identity (email or phone) exists in the database for the given audience [2]. 2. Blocking New Signups: If the identifier is not found, the endpoint returns a 422 Unprocessable Entity error with the message "Signups not allowed for otp" [2]. 3. Intended Usage: This parameter is designed to allow developers to separate sign-up and login flows, specifically to prevent unauthorized or automatic account creation during a sign-in attempt [1]. Because the server checks for the existence of the user to determine whether it is allowed to create one, this behavior can create a side-channel that reveals whether an email or phone number is registered in the system [3]. If the request returns a success (200) or a different error, it implies the user exists; if it returns the "Signups not allowed for otp" error, it indicates the user does not exist [3][2]. In client-side SDKs, this behavior is exposed via the shouldCreateUser option in the signInWithOtp method [4][5]. Setting this option to false is the standard way to restrict account creation [4].
Citations:
- 1: fix: add create_user field to otp endpoint supabase/auth#318
- 2: https://github.com/supabase/auth/blob/f3425cf7/internal/api/otp.go
- 3: signInWithOtp(email: email, shouldCreateUser: false) leaks information about the existence of an account supabase/auth#1547
- 4: https://supabase.com/docs/guides/auth/auth-email-passwordless
- 5: https://github.com/supabase/supabase/blob/master/apps/docs/content/guides/auth/auth-email-passwordless.mdx
Allow OTP delivery for first-time merchant contact emails.
create_user: false makes /auth/v1/otp return 422 "Signups not allowed for otp" when contactEmail is not already a Supabase auth user, so new business inboxes will fail this flow. Use an OTP path that can create the auth identity server-side, or set create_user: true if a contact-only auth record is acceptable.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@relayer-service/server.js` around lines 285 - 299, The OTP request in
server.js is hardcoded with create_user: false, which causes first-time merchant
contact emails to fail with a signup-not-allowed error. Update the auth/v1/otp
flow in the surrounding adminRes fetch block so it can create the Supabase auth
identity for new contactEmail values, either by setting create_user: true or
switching to the appropriate server-side OTP path. Keep the existing request
structure in the fetch call, but ensure the chosen behavior supports first-time
inboxes while preserving the current contactEmail-based flow.
| if (newVerificationStatus) { | ||
| merchantPatch.verification_status = newVerificationStatus; | ||
| merchantPatch.submitted_at = now; | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Stamp reviewed_at when pilot mode auto-approves.
When newVerificationStatus is approved, the KYB row is approved but keeps reviewed_at null. Set it alongside the pilot auto-approval timestamp.
Suggested patch
if (newVerificationStatus) {
merchantPatch.verification_status = newVerificationStatus;
merchantPatch.submitted_at = now;
+ if (newVerificationStatus === 'approved') {
+ merchantPatch.reviewed_at = now;
+ }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (newVerificationStatus) { | |
| merchantPatch.verification_status = newVerificationStatus; | |
| merchantPatch.submitted_at = now; | |
| } | |
| if (newVerificationStatus) { | |
| merchantPatch.verification_status = newVerificationStatus; | |
| merchantPatch.submitted_at = now; | |
| if (newVerificationStatus === 'approved') { | |
| merchantPatch.reviewed_at = now; | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@relayer-service/server.js` around lines 1186 - 1189, The auto-approval path
in the verification update logic leaves reviewed_at unset when
newVerificationStatus becomes approved. Update the merchant patching flow around
the newVerificationStatus block to stamp reviewed_at at the same time as the
pilot auto-approval timestamp, alongside submitted_at, so the approval metadata
is complete.
PR Description:
closes #11
Problem
MerchantRegistrationScreenwas callingsupabase.auth.signInWithOtp({ email: businessEmail })to verify the merchant contact email. This replaced thewallet owner's active Supabase session with the merchant contact email session, which could assign the
merchantsrow to the wrongauth_user_id. Themerchantstable also had no KYB/verification workflow beyondis_active.Changes
Database (
App/supabase_schema.sql)merchants:verification_status(pending/approved/rejected),verified_contact_email,contact_email_verified,submitted_at,reviewed_at,rejection_reasonmerchant_contact_verificationstable audits OTP send/verify events without touching auth sessionsget_own_merchant_by_walletRPC to return all new columnsupdated_attrigger for the new tableRelayer (
relayer-service/server.js)POST /merchants/send-contact-otp— sends email OTP via Supabase Admin API server-side. The caller's auth session is never changed. Validateswallet owner via
auth_user_id.POST /merchants/verify-contact-otp— verifies OTP server-side. On success patchescontact_email_verified = trueand auto-approves the merchantwhen
PILOT_MODE=true.App services
auth.ts: AddedsendMerchantContactOtpandverifyMerchantContactOtp— both call the relayer with the current session bearer token. NosignInWithOtpcalls.merchant.ts: ExtendedMerchantinterface with all KYB fields.registerAsMerchantsetsverification_status = 'approved'in pilot mode,'pending'in production.Screens
MerchantRegistrationScreen: Creates a draft merchant row (is_active=false) before sending OTP somerchantIdcan be passed to the relayer. On"Register", updates the draft row instead of re-inserting (avoids
UNIQUE wallet_addressviolation). Session is never replaced.MerchantDashboardScreen: Shows pending / approved / rejected status banner above the Business status section.MerchantQRGeneratorScreenandMerchantGlobalQRScreen: Block QR generation for unapproved merchants with an alert + navigate back.Pilot mode safety
PILOT_MODE=true(relayer default) auto-approves merchants on OTP verify, so the existing pilot flow is unchanged. Production (PILOT_MODE=false)leaves merchants in
pendingstate for admin review.Verification
npx tsc --noEmit— 0 errorsnode --check relayer-service/server.js— 0 errorssupabase.auth.currentUseris still the wallet owner, not the merchant contact emailmerchants.auth_user_idalways matches the original wallet owner'sauth.uid()Summary by CodeRabbit
New Features
Bug Fixes
Backend/Platform