athanasios papadopoulos

Live in production

Avgoro

A live marketplace for local egg producers — Stripe Connect payouts and database-enforced authorisation, on web and React Native.

role:
Sole designer and engineer
period:
2026

source: private repository.Given the nature of this project and its security, the code is kept in a private repository, so there is no GitHub link. Every code excerpt on this page is taken directly from it.

The web platform: search by area, choose a producer, follow the order.
The React Native app: both ends of an order, on the buyer's phone and the producer's.

Avgoro is a two-sided marketplace in production at avgoro.gr. Producers set their own prices, buyers search by area to find producers in their region, and the platform settles payments between them. It ships on two surfaces: a buildless static web client and a React Native application.

Two clients, one trust boundary

The web platform is deliberately framework-free — plain HTML, vanilla JavaScript and a single stylesheet, assembled at deploy time by four small build scripts. The mobile app is React Native 0.87 across sixteen screens, including KYC submission, courier tracking, in-app messaging and an admin surface, with Firebase push, Google Sign-In and the native Stripe payment sheet.

Both authenticate with the same public anon key, and neither is trusted. Every authorisation decision happens in PostgreSQL.

Security as schema, not as code

Roughly forty ordered SQL migrations carry the platform’s security posture, and the interesting ones are not features. Fee and price arithmetic is recomputed by database triggers so a tampered request body cannot change what an order costs. Order state moves only through a function that validates the transition. Administrative actions require a session that has passed two-factor authentication.

Live demo

Try to cheat the checkout

Avgoro's checkout request carries no prices at all. Edit the request, or try one of the attacks, and see what the server makes of it. This runs a port of the checkout function's rules, checked by tests against the real source. The real code is in the walkthrough below, under prices recomputed server-side.

A dedicated test suite asserts the policies hold, including the negative cases: that ordinary roles cannot execute the pricing engine.

Checkout and settlement

  1. 01 A public key by design
  2. 02 Row-level security as the only boundary
  3. 03 RLS policies under test
  4. 04 Prices recomputed server-side
  5. 05 Ordered stock locks
  6. 06 Fee integrity in the database
  7. 07 Order status through a guarded RPC

A public key by design

The browser and the mobile app both authenticate to Supabase with an anon key shipped in the client and visible to anyone. What the client does hold is session state, and this is how it keeps that coherent across tabs.

WhyThe anon key is not a secret and was never meant to be one, which means row-level security is not a defence-in-depth layer here — it is the only thing standing between a curious visitor and every row in the database. What the client is trusted with is narrow: coordinating its own token refresh. Two tabs refreshing at once can trip reuse detection and sign the user out, so refreshes are serialised through the Navigator LockManager, with an unlocked fallback for the contexts where the lock cannot be acquired.

assets/src/00-config.js · cross-tab auth token coordination
// auth.lock: supabase-js coordinates token refresh across tabs with the
// Navigator LockManager. Keeping that coordination matters: with refresh-token
// rotation, two tabs refreshing at once can trip reuse detection and log the
// user out. In some contexts (certain local servers, backgrounded tabs) the
// lock request itself rejects ("Acquiring an exclusive Navigator LockManager
// lock … immediately failed"); only then do we run the function unlocked.
async function avgAuthLock(name, acquireTimeout, fn) {
  if (!(navigator.locks && navigator.locks.request)) return await fn();
  let started = false;
  try {
    return await navigator.locks.request(name, { mode: 'exclusive' }, async () => {
      started = true;
      return await fn();
    });
  } catch (e) {
    if (started) throw e;       // fn itself failed: don't run it twice
    return await fn();          // the lock could not be acquired
  }
}

Row-level security as the only boundary

Every table carries policies deciding which rows a given session may read or write. This one gates producer KYC — the most sensitive table on the platform — to the row's owner or an admin.

WhyBecause the anon key is public and the client cannot be trusted with authorisation, the database performs it on every single read. Expressing that as a policy on the table rather than a filter in a query means it holds for every caller, including ones written years later by someone who never read this file.

kyc-pii-lockdown-v2.sql · owner-or-admin read policy on producer KYC
ALTER TABLE public.producer_kyc ENABLE ROW LEVEL SECURITY;

-- Only the owning producer or an admin may read. There are deliberately NO
-- write policies: all writes go through submit_kyc (SECURITY DEFINER).
DROP POLICY IF EXISTS "Owner or admin reads producer_kyc" ON public.producer_kyc;
CREATE POLICY "Owner or admin reads producer_kyc"
  ON public.producer_kyc FOR SELECT TO authenticated
  USING (
    public.is_admin()
    OR producer_id IN (SELECT id FROM public.producer_profiles WHERE user_id = auth.uid())
  );

RLS policies under test

A dedicated SQL test suite asserts the policies actually hold, including that privileged functions are not executable by ordinary roles.

WhySecurity policy that is never tested is security policy that silently regresses on the next migration. Asserting the negative — that a role does not have a privilege — is the part people skip, and it is the part that catches a careless GRANT.

security-rls-tests.sql · privilege assertions
SELECT pg_temp.expect(NOT has_function_privilege('anon', 'public._log_admin_event(text,text,text)', 'EXECUTE'),
  'anon cannot forge admin audit events');
SELECT pg_temp.expect(NOT has_function_privilege('authenticated', 'public.recompute_order(uuid)', 'EXECUTE'),
  'authenticated cannot recompute order fees');
-- recompute_order must be step 39's single definition. Two migrations used to
-- define it, and whichever ran last won: step 39's platform-only copy silently
-- dropped per-farm delivery fees at payment, and step 38d's copy dropped the
-- pending guard. Both properties are asserted so neither regression can return.
SELECT pg_temp.expect(pg_get_functiondef('public.recompute_order(uuid)'::regprocedure) LIKE '%pp.delivery_fee%',
  'recompute_order charges the farm''s own delivery fee');
SELECT pg_temp.expect(pg_get_functiondef('public.recompute_order(uuid)'::regprocedure) LIKE '%IS DISTINCT FROM ''pending''%',
  'recompute_order never rewrites a non-pending order''s amounts');
SELECT pg_temp.expect(pg_get_functiondef('public.recompute_order(uuid)'::regprocedure) LIKE '%pg_temp%',
  'recompute_order keeps the hardened search_path');
SELECT pg_temp.expect(NOT has_function_privilege('anon', 'public.sweep_abandoned_orders()', 'EXECUTE'),
  'anon cannot run the order sweep');
SELECT pg_temp.expect(NOT has_function_privilege('anon', 'public.set_order_status(uuid,text)', 'EXECUTE'),
  'anon cannot call set_order_status');
SELECT pg_temp.expect(has_function_privilege('anon', 'public.get_order_public(uuid,uuid)', 'EXECUTE'),
  'anon CAN call get_order_public (guest tracking)');

Prices recomputed server-side

The create-checkout edge function ignores whatever totals the client sent and recomputes prices, subtotal, commission and delivery fee from the database.

WhyA client that can name its own price will eventually name zero. Recomputing every amount server-side makes the request body a statement of intent rather than a source of truth, which removes an entire class of tampering without needing to detect it.

create-checkout/index.ts · the security contract, stated up front
// create-checkout — recompute amounts server-side, create ONE order per
// producer in the cart, and open a single Stripe PaymentIntent for the whole
// cart. Payouts use SEPARATE CHARGES & TRANSFERS: the platform is charged the
// full amount, then the webhook transfers each producer's share on success.
//
// Request body (JSON):
//   { items: [{ productId, qty }], orderType: "b2c"|"b2b",
//     customer: { name, phone, email, address, city?, postcode?, notes? },
//     invoiceRequested?, invoiceDetails?,
//     replaceClientSecret?: "pi_..._secret_..." }  ← previous attempt from the same checkout
//
// Returns: { clientSecret, orderIds, paymentIntentId }
//
// SECURITY: prices, subtotal, commission and delivery fee are recomputed from
// the database here — the client's numbers are never trusted. Orders can only
// be created here (the browser has no INSERT on orders / order_items).
// Every producer in the cart must be trade-ready and not hidden, and every
// product must be live (not archived).
//
// Deploy: supabase functions deploy create-checkout

import { buildCorsHeaders, json } from "../_shared/cors.ts";

On mobile — Native payment sheet

The app calls the same edge function, then hands the resulting client secret to the Stripe React Native payment sheet instead of following a web redirect.

The payment surface differs but the trust boundary does not. The app is just another untrusted client of the same server-side pricing, which is exactly why adding a second platform did not require a second security model.

CheckoutScreen.js · initPaymentSheet and presentPaymentSheet
const orderIds = out.orderIds || (out.orderId ? [out.orderId] : []);
const {initPaymentSheet, presentPaymentSheet} = require('@stripe/stripe-react-native');

const {error: initError} = await initPaymentSheet({
  paymentIntentClientSecret: out.clientSecret,
  merchantDisplayName: 'avgoro.gr',
});

if (initError) {
  showToast('Σφάλμα: ' + initError.message);
  setSubmitting(false);
  return;
}

const {error: presentError} = await presentPaymentSheet();
setSubmitting(false);

if (presentError) {
  if (presentError.code === 'Canceled') {
    showToast('Η πληρωμή ακυρώθηκε');
    return;
  }
  showToast('Σφάλμα πληρωμής: ' + presentError.message);
  return;

Ordered stock locks

Concurrent checkouts acquire per-product stock locks sorted by product id.

WhyTwo carts containing the same two products, locking in opposite orders, deadlock. Sorting by product id gives every transaction one global lock ordering, which makes the deadlock structurally impossible rather than merely unlikely.

create-checkout/index.ts · deadlock-free stock reservation
  if (orderErr) throw new Error(`order insert: ${orderErr.message}`);
  createdOrderIds.push(order.id);

  // Sorted by product_id so concurrent checkouts take stock locks in the
  // same order (no deadlocks). The enforce_order_item_price trigger
  // re-derives price, producer, farm name, type and unit from products.
  const sortedLines = [...g.lines].sort((a, b) => (a.product_id < b.product_id ? -1 : 1));
  const { error: itemsErr } = await supabaseAdmin
    .from("order_items").insert(sortedLines.map((l) => ({ ...l, order_id: order.id })));
  if (itemsErr) {
    if (/insufficient_stock/.test(itemsErr.message)) {
      throw Object.assign(new Error("insufficient_stock"), { userFacing: true });
    }
    throw new Error(`items insert: ${itemsErr.message}`);
  }
}

// ---- 5. Authoritative grand total (triggers may have recomputed) ----
const { data: dbOrders, error: totErr } = await supabaseAdmin
  .from("orders").select("total").in("id", createdOrderIds);

Fee integrity in the database

A trigger recomputes the order total whenever items change, so the stored total can never disagree with the stored line items.

WhyPutting the invariant in the database rather than the application means it holds for every writer — the web client, the app, a future admin tool, or a manual SQL fix at two in the morning. Application-layer invariants only bind the applications that remember them.

fees-setup.sql · recompute_order and its trigger function
CREATE OR REPLACE FUNCTION public.recompute_order(p_order_id uuid)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
  s public.platform_settings;
  o public.orders;
  sub numeric;
  rate numeric;
BEGIN
  SELECT * INTO s FROM public.platform_settings WHERE id = 1;
  SELECT * INTO o FROM public.orders WHERE id = p_order_id;
  IF o.id IS NULL THEN RETURN; END IF;

  SELECT COALESCE(SUM(line_price), 0) INTO sub FROM public.order_items WHERE order_id = p_order_id;
  rate := CASE WHEN o.order_type = 'b2b' THEN COALESCE(s.commission_b2b, 0) ELSE COALESCE(s.commission_b2c, 0) END;

  UPDATE public.orders SET
    subtotal     = sub,
    commission   = round(sub * rate, 2),
    delivery_fee = COALESCE(s.delivery_fee, 0),
    total        = sub + COALESCE(s.delivery_fee, 0)
  WHERE id = p_order_id;
END;
$$;

-- 4) Triggers: recompute when items change, or when order_type changes
CREATE OR REPLACE FUNCTION public.trg_recompute_from_items()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
BEGIN
  PERFORM public.recompute_order(COALESCE(NEW.order_id, OLD.order_id));
  RETURN NULL;
END;
$$;

Order status through a guarded RPC

Order state changes go through set_order_status, which validates the transition rather than accepting an arbitrary status string.

WhyAllowing direct UPDATE on a status column means any actor who can write the row can move an order to 'paid'. Funnelling transitions through a function that checks legality turns the state machine into something the database enforces.

security-hardening-2026-09b.sql · set_order_status
CREATE OR REPLACE FUNCTION public.set_order_status(p_order_id uuid, p_status text)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
  v_current text;
  v_owner   uuid;
BEGIN
  IF p_status IS NULL OR p_status NOT IN
     ('pending', 'paid', 'confirmed', 'preparing', 'out_for_delivery', 'delivered', 'cancelled') THEN
    RAISE EXCEPTION 'Invalid status: %', p_status;
  END IF;

  SELECT status, user_id INTO v_current, v_owner
    FROM public.orders WHERE id = p_order_id
     FOR UPDATE;
  IF NOT FOUND THEN
    RAISE EXCEPTION 'Order not found';
  END IF;

  IF v_current = p_status THEN
    RETURN;  -- idempotent no-op
  END IF;

  IF p_status = 'cancelled' AND v_current <> 'pending' THEN
    RAISE EXCEPTION 'paid_order_needs_refund'
      USING ERRCODE = 'check_violation',
            HINT = 'A paid order is cancelled by refunding it (create-refund).';
  END IF;

  -- Admins may correct mistakes between fulfilment steps, but never mark an
  -- order paid (only the payment webhook does), reopen a cancelled order
  -- (its stock was already released) or send it back to pending.
  IF public.is_admin() THEN
    IF v_current = 'cancelled' OR p_status IN ('pending', 'paid') THEN
      RAISE EXCEPTION 'Illegal status change: % -> %', v_current, p_status;
    END IF;
    UPDATE public.orders SET status = p_status WHERE id = p_order_id;
    RETURN;
  END IF;

  IF NOT public._is_legal_transition(v_current, p_status) THEN
    RAISE EXCEPTION 'Illegal status transition: % -> %', v_current, p_status;
  END IF;

  IF EXISTS (
    SELECT 1 FROM public.order_items oi
      JOIN public.producer_profiles pp ON pp.id = oi.producer_id
     WHERE oi.order_id = p_order_id AND pp.user_id = auth.uid()
  ) THEN
    IF p_status NOT IN ('preparing', 'out_for_delivery', 'delivered', 'cancelled') THEN
      RAISE EXCEPTION 'Producers cannot set status to %', p_status;
    END IF;
    UPDATE public.orders SET status = p_status WHERE id = p_order_id;
    RETURN;
  END IF;

  -- Customer: only cancel their own unpaid order.
  IF p_status = 'cancelled' AND v_owner IS NOT NULL AND v_owner = auth.uid() THEN
    UPDATE public.orders SET status = 'cancelled' WHERE id = p_order_id AND status = 'pending';
    RETURN;
  END IF;

  -- Customer: confirm receipt of their own dispatched order. Only the buyer
  -- knows when a parcel actually arrived, and under producer-ships the farm
  -- only knows when it handed the box to the courier. No money depends on this
  -- (transfers happen at payment_intent.succeeded), so it governs review
  -- eligibility and closing the order, not funds. The status guard keeps it to
  -- the one legal transition; guests have no user_id and are closed by
  -- sweep_delivered_orders() instead.
  IF p_status = 'delivered' AND v_owner IS NOT NULL AND v_owner = auth.uid() THEN
    UPDATE public.orders SET status = 'delivered'
     WHERE id = p_order_id AND status = 'out_for_delivery';
    RETURN;
  END IF;

  RAISE EXCEPTION 'Not authorized to change order status';
END;
$$;

On mobile — Push instead of refresh

When the status changes, the app receives a Firebase message and updates in place rather than waiting for the user to reload.

Delivering state changes over a push channel means the server decides when a client learns something, rather than clients polling for it. The notification carries no order detail — only enough to prompt an authenticated fetch.

pushNotifications.js · setupForegroundHandler (onMessage)
export function setupForegroundHandler(onNotification) {
  if (!fbMessaging || !fbFns) return () => {};
  try {
    return fbFns.onMessage(fbMessaging, async remoteMessage => {
      if (onNotification) onNotification(remoteMessage);
    });
  } catch {
    return () => {};
  }
}