Lovable and Supabase
This setup is prepared for testing and has not yet completed a live installation on a blank customer project. Do not apply it to an existing live site until that check is complete.
Your Sniffopotamus key stays on your server. The component uses your site's existing Supabase client and calls your own function. Only product links you have approved can show a card. A failure leaves your listing's own content alone.
1. Collect your key and approve a product
Use the Builder Console to collect your business key, approve a product link and save a look. Keep the key out of browser code. Use your own product reference below.
2. Prepare the private stored-copy table
In your own test project's Supabase dashboard, open the SQL editor and run this file once. It creates a private cache, with no browser access. It does not belong in the Sniffopotamus database.
-- Customer setup sample only. Do not apply to the Sniffopotamus database.
-- reads: integrations/website-block/supabase-edge/sniff-card/index.ts
-- product_ref holds a hash of the partner key, product reference and look.
create table public.sniff_stored_cards (
product_ref text primary key,
etag text,
body text,
fetched_at timestamptz not null,
checked_at timestamptz not null
);
alter table public.sniff_stored_cards enable row level security;
revoke all on public.sniff_stored_cards from public, anon, authenticated;
grant select, insert, update, delete on public.sniff_stored_cards to service_role;
3. Create your function
Create an Edge Function named sniff-card. Keep Supabase's default platform verification enabled. Paste the following file as index.ts, then the next file as core.ts alongside it. The function accepts only the origins you list in the next step.
import {sniffCardCore,MAX_CARD_BYTES,type Stored} from './core.ts';
// Type surface only; the real Deno runtime supplies these globals on Supabase.
declare const Deno:{env:{get(name:string):string|undefined};serve(handler:(request:Request)=>Promise<Response>):void};
type Options={env:(name:string)=>string|undefined;fetch:typeof fetch;now?:()=>Date};
export function createSniffEdgeHandler(options:Options){
// Isolate-local only: the shared database is not a distributed request lock.
const pending=new Map<string,Promise<Response>>();
return async(request:Request):Promise<Response>=>{
const origin=request.headers.get('origin');
const allowed=(options.env('SNIFF_ALLOWED_ORIGINS')??'').split(',').map(value=>value.trim()).filter(value=>{try{const u=new URL(value);return /^https?:$/.test(u.protocol)&&u.origin===value;}catch{return false;}});
if(!origin||!allowed.includes(origin))return new Response(null,{status:403});
const headers={'Access-Control-Allow-Origin':origin,'Access-Control-Allow-Headers':'authorization, x-client-info, apikey, content-type','Access-Control-Allow-Methods':'GET, OPTIONS','Vary':'Origin','Cache-Control':'private, no-store'};
if(request.method==='OPTIONS')return new Response(null,{status:204,headers});
if(request.method!=='GET')return new Response(null,{status:405,headers:{...headers,Allow:'GET'}});
const url=new URL(request.url),product=url.searchParams.get('product')??'',look=url.searchParams.get('look');
if(!product.trim()||product.length>200||(look!==null&&look.length>200))return new Response(null,{status:400,headers});
const partnerKey=options.env('SNIFF_PARTNER_KEY'),projectKey=options.env('SUPABASE_SERVICE_ROLE_KEY'),project=options.env('SUPABASE_URL');
if(!partnerKey||!projectKey||!project)return new Response(null,{status:503,headers});
try{
const base=new URL(project);if(base.protocol!=='https:'||base.username||base.password||base.pathname!=='/'||base.search||base.hash)throw Error('project_url');
const bytes=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(JSON.stringify([partnerKey,product,look])));
const scope=Array.from(new Uint8Array(bytes),value=>value.toString(16).padStart(2,'0')).join('');
const pendingKey=`${base.origin}:${scope}`;
const copyForOrigin=(response:Response)=>{const copy=response.clone();copy.headers.set('Access-Control-Allow-Origin',origin);return copy;};
const existing=pending.get(pendingKey);if(existing)return copyForOrigin(await existing);
if(pending.size>=200)return new Response(null,{status:503,headers});
const work=(async()=>{
const query=new URLSearchParams({product_ref:`eq.${scope}`,select:'etag,body,fetched_at,checked_at',order:'product_ref.asc',limit:'1'});
const table=`${base.origin}/rest/v1/sniff_stored_cards`;
const projectHeaders={apikey:projectKey,Authorization:`Bearer ${projectKey}`};
const storedResponse=await options.fetch(`${table}?${query}`,{headers:projectHeaders,redirect:'error',signal:AbortSignal.timeout(10000)});
if(!storedResponse.ok)throw Error('store_read');
const rows=await storedResponse.json();if(!Array.isArray(rows))throw Error('store_shape');
const row=rows[0];if(typeof row?.body==='string'&&(row.body.includes(partnerKey)||row.body.includes(projectKey)))throw Error('unsafe_stored_body');let stored:Stored|null=null;
if(row&&typeof row.body==='string'&&typeof row.fetched_at==='string'&&typeof row.checked_at==='string')stored={body:row.body,etag:typeof row.etag==='string'?row.etag:null,fetchedAt:new Date(row.fetched_at),checkedAt:new Date(row.checked_at)};
const result=await sniffCardCore({productRef:product,look,now:options.now?.()??new Date(),stored,fetchCard:async(etag)=>{
const params=new URLSearchParams({format:'html'});if(look)params.set('look',look);
const response=await options.fetch(`https://www.sniffopotamus.com/api/partner/v1/products/${encodeURIComponent(product)}/card?${params}`,{headers:{Authorization:`Bearer ${partnerKey}`,...(etag?{'If-None-Match':etag}:{})},redirect:'error',signal:AbortSignal.timeout(10000)});
if(response.status!==200)return {status:response.status};
if(response.headers.get('content-type')?.split(';')[0].trim().toLowerCase()!=='text/html')return {status:404};
const reader=response.body?.getReader(),chunks:Uint8Array[]=[];let size=0;
if(reader){try{while(true){const part=await reader.read();if(part.done)break;size+=part.value.byteLength;if(size>MAX_CARD_BYTES){await reader.cancel();return {status:503};}chunks.push(part.value);}}finally{reader.releaseLock();}}
const bodyBytes=new Uint8Array(size);let offset=0;for(const chunk of chunks){bodyBytes.set(chunk,offset);offset+=chunk.byteLength;}
const body=new TextDecoder().decode(bodyBytes);if(body.includes(partnerKey)||body.includes(projectKey))return {status:404};
return {status:200,body,etag:response.headers.get('etag')??undefined};
}});
if(result.store!==stored){
const write=result.store?await options.fetch(`${table}?on_conflict=product_ref`,{method:'POST',headers:{...projectHeaders,'Content-Type':'application/json',Prefer:'resolution=merge-duplicates,return=minimal'},body:JSON.stringify({product_ref:scope,etag:result.store.etag,body:result.store.body,fetched_at:result.store.fetchedAt.toISOString(),checked_at:result.store.checkedAt.toISOString()}),redirect:'error',signal:AbortSignal.timeout(10000)}):await options.fetch(`${table}?${new URLSearchParams({product_ref:`eq.${scope}`})}`,{method:'DELETE',headers:projectHeaders,redirect:'error',signal:AbortSignal.timeout(10000)});
if(!write.ok)throw Error('store_write');
}
return new Response(result.serve,{status:result.serve===null?204:200,headers:{...headers,...(result.serve!==null?{'Content-Type':'text/html; charset=utf-8'}:{})}});
})();pending.set(pendingKey,work);
try{return copyForOrigin(await work);}finally{pending.delete(pendingKey);}
}catch{return new Response(null,{status:503,headers});}
};
}
// Keep Supabase's default platform verification enabled. No JWT bypass flag.
if(typeof Deno!=='undefined')Deno.serve(createSniffEdgeHandler({env:name=>Deno.env.get(name),fetch:globalThis.fetch}));
// Self-contained for the dashboard's two-file function editor. Parity checked
// against ../shared/constants.ts in the helper tests; no Node dependencies.
export const STORED_CARD_RECHECK_MINUTES=15;
export const STORED_CARD_KEEP_ON_ERROR_DAYS=7;
export const MAX_CARD_BYTES=262144;
export type Stored={etag:string|null;body:string;fetchedAt:Date;checkedAt:Date};
export type CoreInput={productRef:string;look:string|null;now:Date;stored:Stored|null;fetchCard:(etag:string|null)=>Promise<{status:number;etag?:string;body?:string}>};
export async function sniffCardCore(input:CoreInput):Promise<{serve:string|null;store:Stored|null}>{
const empty={serve:null,store:null};const now=+input.now;
if(!input.productRef||input.productRef.length>200||!input.productRef.trim()||!Number.isFinite(now))return empty;
let stored=input.stored;
if(stored&&(!Number.isFinite(+stored.checkedAt)||!Number.isFinite(+stored.fetchedAt)||+stored.checkedAt>now||+stored.fetchedAt>+stored.checkedAt||new TextEncoder().encode(stored.body).byteLength>MAX_CARD_BYTES))stored=null;
const age=stored?now-(+stored.checkedAt):Infinity;
if(stored&&age<STORED_CARD_RECHECK_MINUTES*60000)return {serve:stored.body,store:stored};
const keep=()=>stored&&age<STORED_CARD_KEEP_ON_ERROR_DAYS*86400000?{serve:stored.body,store:stored}:empty;
try{
const answer=await input.fetchCard(stored?.etag??null);
if(answer.status===304)return stored?{serve:stored.body,store:{...stored,checkedAt:new Date(now)}}:empty;
if(answer.status>=500||answer.status===429)return keep();
if(answer.status!==200||typeof answer.body!=='string'||new TextEncoder().encode(answer.body).byteLength>MAX_CARD_BYTES)return empty;
const next={etag:answer.etag??null,body:answer.body,fetchedAt:new Date(now),checkedAt:new Date(now)};
return {serve:next.body,store:next};
}catch{return keep();}
}
4. Add the server secrets
In the dashboard's Edge Function secrets, add SNIFF_PARTNER_KEY with your business key and SNIFF_ALLOWED_ORIGINS with your site's exact origin, such as https://shop.example. Separate additional origins with commas. Do not use a wildcard or include a page path. Supabase supplies SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY to the function; never copy its service key into your component.
5. Add the component
Copy this component into your site. Pass your existing Supabase client as client, your own approved product reference as productRef, and optionally your saved look name as look. It calls the function using the site's existing Supabase client configuration. It does not accept a Sniffopotamus key.
'use client';
import {useEffect,useState} from 'react';
// Pass your existing shop Supabase client. Never pass a Sniffopotamus key.
type ShopClient={functions:{invoke:(name:string,options:{method:'GET'})=>Promise<{data:unknown;error:unknown}>}};
export function SniffCard({client,productRef,look}:{client:ShopClient;productRef:string;look?:string}){
const [card,setCard]=useState<{productRef:string;look?:string;html:string}|null>(null);
useEffect(()=>{
let active=true;setCard(null);
if(!productRef.trim()||productRef.length>200||(look&&look.length>200))return;
const query=new URLSearchParams({product:productRef});if(look)query.set('look',look);
void client.functions.invoke(`sniff-card?${query}`,{method:'GET'}).then(({data,error})=>{
if(active&&!error&&typeof data==='string')setCard({productRef,look,html:data});
}).catch(()=>{if(active)setCard(null);});
return()=>{active=false;};
},[client,productRef,look]);
if(!card||card.productRef!==productRef||card.look!==look)return null;
// Only the fixed Sniffopotamus server route's escaped HTML belongs here.
return <div dangerouslySetInnerHTML={{__html:card.html}}/>;
}
6. Check one product
Open a page for one approved product. Its card should show. Check an unlinked product too: the slot should stay empty. Confirm the page source and browser requests contain no Sniffopotamus key. The live blank-project check for this release remains pending.
A stored card is checked again after 15 minutes. An unchanged card keeps its copy; an upstream error can retain a previously checked copy for up to seven days. An access refusal or unlinked product hides the stored card on the next re-check.
Supabase's standard function domain may return HTML with a plain-text media type; the component uses the returned string. It does not require a custom domain. The origin list restricts browser access; it is not proof of an individual shopper's identity.
The function shares matching requests inside one running instance. Separate instances can still fetch the same uncached product at once; the live installation check has not yet assessed that case. Exhausted allowance currently refuses before an unchanged card can be re-confirmed, so the last checked copy can remain only within the seven-day limit.
How to disconnect
Remove the component from your page, revoke the business key in the Builder Console, and disable or archive your test function. Retain the private cache table while you confirm the disconnect; archive its contents before any later removal. Do not delete an existing customer project's tables as part of this example.