From 6d202446b24e27c5438f134d9d50c61a67532325 Mon Sep 17 00:00:00 2001 From: feispro <262678496+feiscs@users.noreply.github.com> Date: Fri, 15 May 2026 04:32:00 -0400 Subject: [PATCH 1/2] Harden Stripe signature parsing and enforce explicit Supabase insert-only grants --- .env.example | 25 ++-- api/stripe-webhook.js | 89 +++++++++++++ docs/GO_LIVE_CHECKLIST.md | 3 +- docs/INTEGRATIONS.md | 17 ++- docs/SUPABASE_DEPLOY_VERIFY.md | 75 +++++++++++ .../202605130002_harden_storefront_rls.sql | 26 ++++ .../202605150001_analytics_rollup.sql | 121 ++++++++++++++++++ 7 files changed, 343 insertions(+), 13 deletions(-) create mode 100644 api/stripe-webhook.js create mode 100644 docs/SUPABASE_DEPLOY_VERIFY.md create mode 100644 supabase/migrations/202605150001_analytics_rollup.sql diff --git a/.env.example b/.env.example index 938561a..a1cb53e 100644 --- a/.env.example +++ b/.env.example @@ -1,26 +1,29 @@ -# FORMA public runtime configuration for local demos or Vercel builds. -# Copy to .env locally if needed. Never commit .env with real values. - -# Shopify Storefront API (browser-safe storefront token, not Admin API token) +# Shopify SHOPIFY_DOMAIN=feispla.myshopify.com SHOPIFY_STOREFRONT_ACCESS_TOKEN= SHOPIFY_API_VERSION=2025-04 -SHOPIFY_ENABLE_REMOTE_PRODUCTS=true +SHOPIFY_ENABLE_REMOTE_PRODUCTS=false SHOPIFY_PRODUCT_LIMIT=50000 -# Supabase public anon configuration +# Supabase SUPABASE_URL=https://nejzzerwtgtbqawaizuo.supabase.co -SUPABASE_ANON_KEY=sb_publishable_BPOIpRJaqBftujcnHY0mvw_jh8-88Kh +SUPABASE_ANON_KEY=sb_publishable_xxx SUPABASE_EVENTS_TABLE=store_events SUPABASE_NEWSLETTER_TABLE=newsletter_signups -# Chatbase widget +# Chatbase CHATBASE_BOT_ID= CHATBASE_ENABLED=false -# Optional agent metadata +# GitHub AI metadata GITHUB_AGENT_PROVIDER=Claude GITHUB_AGENT_REPO=FEISHTML +GITHUB_AGENT_WORKFLOW=Plan → branch → PR → Vercel preview → review → merge + +# Stripe (server-side) +STRIPE_SECRET_KEY=sk_live_or_sk_test_xxx +STRIPE_PUBLISHABLE_KEY=pk_live_or_pk_test_xxx +STRIPE_WEBHOOK_SECRET=whsec_xxx -# Optional output override for testing the config generator -FORMA_CONFIG_OUTPUT=assets/config.js +# App +APP_URL=https://feishtml.vercel.app diff --git a/api/stripe-webhook.js b/api/stripe-webhook.js new file mode 100644 index 0000000..02e5bdc --- /dev/null +++ b/api/stripe-webhook.js @@ -0,0 +1,89 @@ +const crypto = require('crypto'); + + +const MAX_TIMESTAMP_TOLERANCE_SECONDS = 300; + +function isTimestampFresh(timestamp) { + const parsed = Number.parseInt(String(timestamp || ''), 10); + if (!Number.isFinite(parsed) || parsed <= 0) return false; + const nowSeconds = Math.floor(Date.now() / 1000); + return Math.abs(nowSeconds - parsed) <= MAX_TIMESTAMP_TOLERANCE_SECONDS; +} + +function parseStripeSignature(signatureHeader) { + return String(signatureHeader || '') + .split(',') + .map((entry) => entry.trim()) + .reduce((acc, pair) => { + const [key, value] = pair.split('='); + if (!key || !value) return acc; + if (key === 'v1') { + acc.v1.push(value); + } else if (key === 't') { + acc.t = value; + } + return acc; + }, { t: '', v1: [] }); +} + +function secureCompare(a, b) { + const first = Buffer.from(String(a || ''), 'utf8'); + const second = Buffer.from(String(b || ''), 'utf8'); + if (first.length !== second.length) return false; + return crypto.timingSafeEqual(first, second); +} + +module.exports = async (req, res) => { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + return res.status(405).json({ ok: false, error: 'Method not allowed' }); + } + + const secret = process.env.STRIPE_WEBHOOK_SECRET; + if (!secret) { + return res.status(500).json({ ok: false, error: 'Missing STRIPE_WEBHOOK_SECRET' }); + } + + const signatureHeader = req.headers['stripe-signature']; + if (!signatureHeader) { + return res.status(400).json({ ok: false, error: 'Missing Stripe-Signature header' }); + } + + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const rawBody = Buffer.concat(chunks).toString('utf8'); + + const parsedSig = parseStripeSignature(signatureHeader); + const timestamp = parsedSig.t; + const signatures = parsedSig.v1; + if (!timestamp || !signatures.length) { + return res.status(400).json({ ok: false, error: 'Malformed Stripe-Signature header' }); + } + + if (!isTimestampFresh(timestamp)) { + return res.status(400).json({ ok: false, error: 'Stale signature timestamp' }); + } + + const payload = `${timestamp}.${rawBody}`; + const expected = crypto.createHmac('sha256', secret).update(payload, 'utf8').digest('hex'); + + const hasValidSignature = signatures.some((signature) => secureCompare(signature, expected)); + if (!hasValidSignature) { + return res.status(400).json({ ok: false, error: 'Invalid signature' }); + } + + let event; + try { + event = JSON.parse(rawBody); + } catch (error) { + return res.status(400).json({ ok: false, error: 'Invalid JSON payload' }); + } + + console.log('[stripe-webhook] verified event', { + id: event.id, + type: event.type, + livemode: event.livemode, + }); + + return res.status(200).json({ ok: true, received: true }); +}; diff --git a/docs/GO_LIVE_CHECKLIST.md b/docs/GO_LIVE_CHECKLIST.md index 385f0b4..a662e10 100644 --- a/docs/GO_LIVE_CHECKLIST.md +++ b/docs/GO_LIVE_CHECKLIST.md @@ -24,7 +24,8 @@ The browser storefront only needs Storefront API access. Do not place private Ad ## 3. Supabase - [ ] Create a Supabase project. -- [ ] Run the Supabase migrations in `supabase/migrations/`. +- [ ] Run the Supabase migrations in `supabase/migrations/` (for hosted project: `supabase link --project-ref ` then `supabase db push`). +- [ ] Run `docs/SUPABASE_DEPLOY_VERIFY.md` SQL checks to confirm analytics tables, policies, and function grants in the target project. - [ ] Confirm row-level security policies in the migration are applied before production traffic; they allow anonymous inserts only, with basic length/shape checks. - [ ] Collect: - `SUPABASE_URL` — configured as `https://nejzzerwtgtbqawaizuo.supabase.co`. diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index d617f43..54e831e 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -71,14 +71,29 @@ SHOPIFY_PRODUCT_LIMIT=5 bundle exec ruby scripts/list_products.rb The static Vercel storefront can keep using `assets/integrations.js` for safe Storefront/demo behavior, while this Ruby adapter handles private Admin API work. + +## Stripe + Vercel webhook path + +1. Rotate any exposed Stripe credentials before configuration. +2. In Vercel Project Settings → Environment Variables, set: + - `STRIPE_SECRET_KEY` + - `STRIPE_PUBLISHABLE_KEY` + - `STRIPE_WEBHOOK_SECRET` (must start with `whsec_`) + - `APP_URL` (for this project: `https://feishtml.vercel.app`) +3. In Stripe Dashboard, configure a webhook endpoint to: + - `https://feishtml.vercel.app/api/stripe-webhook` +4. Select required events (for example `checkout.session.completed`) and use the generated `whsec_...` value in Vercel as `STRIPE_WEBHOOK_SECRET`. +5. Deploy, then send a test event from Stripe and verify `200` responses from the webhook route. + ## Supabase path 1. Create a Supabase project. -2. Run the Supabase migrations in `supabase/migrations/`; it enables RLS and anonymous insert-only policies with basic length/shape checks. +2. Run the Supabase migrations in `supabase/migrations/`; it enables RLS and anonymous insert-only policies with basic length/shape checks. The analytics rollup migration (`202605150001_analytics_rollup.sql`) adds private daily aggregate tables and a secure definer function for backend-only reporting. 3. Put the project URL and anon key into runtime config. The current project URL is `https://nejzzerwtgtbqawaizuo.supabase.co` and the browser key is the provided `sb_publishable_...` key. 4. The storefront will insert: - `cart_add` events into `store_events`. - `newsletter_signup` rows into `newsletter_signups`. +5. The current browser integration is insert-only for Supabase and does not run `SELECT` queries against `store_events` or `newsletter_signups`; this aligns with stricter RLS setups that deny reads to anonymous users. ## Chatbase path diff --git a/docs/SUPABASE_DEPLOY_VERIFY.md b/docs/SUPABASE_DEPLOY_VERIFY.md new file mode 100644 index 0000000..41b2d24 --- /dev/null +++ b/docs/SUPABASE_DEPLOY_VERIFY.md @@ -0,0 +1,75 @@ +# Supabase migration deploy + verification (analytics rollup) + +If `analytics_event_cursor`, `analytics_event_daily`, and `analytics_leads_daily` do not appear in `public`, your local migration file exists but has not been applied to the target Supabase project yet. + +## 1) Deploy migrations to the correct project + +```bash +supabase link --project-ref +supabase db push +``` + +If you use CI, ensure the pipeline that runs `supabase db push` points to the same project ref you are checking in the dashboard. + +## 2) Verify analytics tables exist + +```sql +select schemaname, tablename +from pg_tables +where schemaname = 'public' + and tablename like 'analytics_%' +order by tablename; +``` + +Expected: +- `analytics_event_cursor` +- `analytics_event_daily` +- `analytics_leads_daily` + +## 3) Verify RLS/policies and grants + +```sql +select schemaname, tablename, policyname, permissive, roles, cmd +from pg_policies +where schemaname='public' + and tablename in ('analytics_event_daily', 'analytics_leads_daily') +order by tablename, policyname; +``` + +```sql +select table_schema, table_name, grantee, privilege_type +from information_schema.role_table_grants +where table_schema='public' + and table_name in ('analytics_event_daily', 'analytics_leads_daily') + and grantee in ('anon', 'authenticated', 'service_role') +order by table_name, grantee, privilege_type; +``` + +## 4) Verify function and EXECUTE permissions + +```sql +select n.nspname as schema, p.proname as function_name, + p.prosecdef as security_definer, + pg_get_functiondef(p.oid) as definition +from pg_proc p +join pg_namespace n on n.oid = p.pronamespace +where n.nspname='public' + and p.proname='process_store_events'; +``` + +```sql +select routine_schema, routine_name, grantee, privilege_type +from information_schema.role_routine_grants +where routine_schema='public' + and routine_name='process_store_events' + and grantee in ('anon', 'authenticated', 'service_role') +order by grantee; +``` + +## 5) Validate insert-only frontend model still holds + +From browser-side anon key: +- `INSERT` into `store_events` and `newsletter_signups` should work. +- `SELECT` from those raw tables should be denied. + +This aligns with the current frontend integration, which writes events/newsletter rows and does not read raw tables. diff --git a/supabase/migrations/202605130002_harden_storefront_rls.sql b/supabase/migrations/202605130002_harden_storefront_rls.sql index 5ea6aac..1997121 100644 --- a/supabase/migrations/202605130002_harden_storefront_rls.sql +++ b/supabase/migrations/202605130002_harden_storefront_rls.sql @@ -68,3 +68,29 @@ create policy "Allow anonymous newsletter inserts" and char_length(email) between 3 and 254 and email ~* '^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$' ); + +revoke select on public.store_events from anon, authenticated; +revoke select on public.newsletter_signups from anon, authenticated; + +drop policy if exists "Allow authenticated storefront event inserts" on public.store_events; +create policy "Allow authenticated storefront event inserts" + on public.store_events + for insert + to authenticated + with check ( + char_length(event_name) between 1 and 80 + and jsonb_typeof(payload) = 'object' + and (page_path is null or char_length(page_path) <= 2048) + and (user_agent is null or char_length(user_agent) <= 512) + ); + +drop policy if exists "Allow authenticated newsletter inserts" on public.newsletter_signups; +create policy "Allow authenticated newsletter inserts" + on public.newsletter_signups + for insert + to authenticated + with check ( + source = 'forma-storefront' + and char_length(email) between 3 and 254 + and email ~* '^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$' + ); diff --git a/supabase/migrations/202605150001_analytics_rollup.sql b/supabase/migrations/202605150001_analytics_rollup.sql new file mode 100644 index 0000000..59ae349 --- /dev/null +++ b/supabase/migrations/202605150001_analytics_rollup.sql @@ -0,0 +1,121 @@ +create table if not exists public.analytics_event_cursor ( + id int primary key default 1, + last_event_processed_at timestamptz not null default '1970-01-01 00:00:00+00', + last_lead_processed_at timestamptz not null default '1970-01-01 00:00:00+00' +); + +alter table public.analytics_event_cursor + add column if not exists last_event_processed_at timestamptz not null default '1970-01-01 00:00:00+00'; + +alter table public.analytics_event_cursor + add column if not exists last_lead_processed_at timestamptz not null default '1970-01-01 00:00:00+00'; + +insert into public.analytics_event_cursor (id, last_event_processed_at, last_lead_processed_at) +values (1, '1970-01-01 00:00:00+00', '1970-01-01 00:00:00+00') +on conflict (id) do nothing; + +create table if not exists public.analytics_event_daily ( + day date not null, + event_name text not null, + event_count bigint not null default 0, + primary key (day, event_name) +); + +create table if not exists public.analytics_leads_daily ( + day date primary key, + lead_count bigint not null default 0 +); + +alter table public.analytics_event_daily enable row level security; +alter table public.analytics_leads_daily enable row level security; + +revoke select on public.analytics_event_daily from anon, authenticated; +revoke select on public.analytics_leads_daily from anon, authenticated; + +drop policy if exists "analytics_event_daily select for service role" on public.analytics_event_daily; +create policy "analytics_event_daily select for service role" + on public.analytics_event_daily + for select + to service_role + using (true); + +drop policy if exists "analytics_leads_daily select for service role" on public.analytics_leads_daily; +create policy "analytics_leads_daily select for service role" + on public.analytics_leads_daily + for select + to service_role + using (true); + +create or replace function public.process_store_events() +returns void +language plpgsql +security definer +set search_path = public, pg_temp +as $$ +declare + v_last_event timestamptz; + v_last_lead timestamptz; + v_next_event timestamptz; + v_next_lead timestamptz; +begin + select last_event_processed_at, last_lead_processed_at + into v_last_event, v_last_lead + from public.analytics_event_cursor + where id = 1 + for update; + + if v_last_event is null then + v_last_event := '1970-01-01 00:00:00+00'::timestamptz; + end if; + + if v_last_lead is null then + v_last_lead := '1970-01-01 00:00:00+00'::timestamptz; + end if; + + with src as ( + select + date_trunc('day', created_at)::date as day, + event_name + from public.store_events + where created_at > v_last_event + ), + agg as ( + select day, event_name, count(*)::bigint as event_count + from src + group by day, event_name + ) + insert into public.analytics_event_daily (day, event_name, event_count) + select day, event_name, event_count + from agg + on conflict (day, event_name) + do update + set event_count = public.analytics_event_daily.event_count + excluded.event_count; + + insert into public.analytics_leads_daily (day, lead_count) + select date_trunc('day', created_at)::date as day, count(*)::bigint as lead_count + from public.newsletter_signups + where created_at > v_last_lead + group by 1 + on conflict (day) + do update + set lead_count = public.analytics_leads_daily.lead_count + excluded.lead_count; + + select coalesce(max(created_at), v_last_event) + into v_next_event + from public.store_events + where created_at > v_last_event; + + select coalesce(max(created_at), v_last_lead) + into v_next_lead + from public.newsletter_signups + where created_at > v_last_lead; + + update public.analytics_event_cursor + set + last_event_processed_at = greatest(v_last_event, v_next_event), + last_lead_processed_at = greatest(v_last_lead, v_next_lead) + where id = 1; +end; +$$; + +revoke execute on function public.process_store_events() from anon, authenticated; From 09b66da3be06eb190551ce8871d01943a2c8e8ec Mon Sep 17 00:00:00 2001 From: feispro <262678496+feiscs@users.noreply.github.com> Date: Fri, 15 May 2026 04:40:21 -0400 Subject: [PATCH 2/2] Add follow-up Supabase RLS migration with new filename for hosted deploy --- docs/SUPABASE_DEPLOY_VERIFY.md | 2 ++ ...150002_storefront_insert_only_followup.sql | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 supabase/migrations/202605150002_storefront_insert_only_followup.sql diff --git a/docs/SUPABASE_DEPLOY_VERIFY.md b/docs/SUPABASE_DEPLOY_VERIFY.md index 41b2d24..c8593b4 100644 --- a/docs/SUPABASE_DEPLOY_VERIFY.md +++ b/docs/SUPABASE_DEPLOY_VERIFY.md @@ -2,6 +2,8 @@ If `analytics_event_cursor`, `analytics_event_daily`, and `analytics_leads_daily` do not appear in `public`, your local migration file exists but has not been applied to the target Supabase project yet. +Important: hosted deploy pipelines commonly apply only new migration filenames. The follow-up RLS hardening is in `202605150002_storefront_insert_only_followup.sql`, so do not rely on edits to previously applied migration files. + ## 1) Deploy migrations to the correct project ```bash diff --git a/supabase/migrations/202605150002_storefront_insert_only_followup.sql b/supabase/migrations/202605150002_storefront_insert_only_followup.sql new file mode 100644 index 0000000..aedde2e --- /dev/null +++ b/supabase/migrations/202605150002_storefront_insert_only_followup.sql @@ -0,0 +1,32 @@ +-- Follow-up migration: keep storefront tables insert-only for browser roles. +-- This is intentionally a new file so hosted Supabase deploy pipelines apply it +-- even when earlier migrations were already executed. + +alter table public.store_events enable row level security; +alter table public.newsletter_signups enable row level security; + +revoke select on public.store_events from anon, authenticated; +revoke select on public.newsletter_signups from anon, authenticated; + +drop policy if exists "Allow authenticated storefront event inserts" on public.store_events; +create policy "Allow authenticated storefront event inserts" + on public.store_events + for insert + to authenticated + with check ( + char_length(event_name) between 1 and 80 + and jsonb_typeof(payload) = 'object' + and (page_path is null or char_length(page_path) <= 2048) + and (user_agent is null or char_length(user_agent) <= 512) + ); + +drop policy if exists "Allow authenticated newsletter inserts" on public.newsletter_signups; +create policy "Allow authenticated newsletter inserts" + on public.newsletter_signups + for insert + to authenticated + with check ( + source = 'forma-storefront' + and char_length(email) between 3 and 254 + and email ~* '^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$' + );