Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
89 changes: 89 additions & 0 deletions api/stripe-webhook.js
Original file line number Diff line number Diff line change
@@ -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 });
};
3 changes: 2 additions & 1 deletion docs/GO_LIVE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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`.
Expand Down
17 changes: 16 additions & 1 deletion docs/INTEGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
77 changes: 77 additions & 0 deletions docs/SUPABASE_DEPLOY_VERIFY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# 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.

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
supabase link --project-ref <your-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.
26 changes: 26 additions & 0 deletions supabase/migrations/202605130002_harden_storefront_rls.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,}$'
);
Loading