feat(web): blog-post ingestion webhook + /blog, dynamic RSS & sitemap

Add an autoblog webhook receiver and a Supabase-backed blog to logicsrc-web
(the app had no Supabase usage before).

- Migration: blog_posts table (RLS: public reads published, service-role
  writes). Applied to the linked project.
- POST /api/webhooks/blog: verifies the Standard Webhooks signature against
  BLOG_WEBHOOK_SECRET via @profullstack/autoblog verifyAndParse (no admin
  user — shared secret only) and upserts the post by slug.
- /blog index + /blog/[slug] render published posts from the table.
- /blog/rss.xml and /sitemap.xml are now dynamic, generated from the table;
  removed the static public/sitemap.xml and public/blog/rss.xml.
- BLOG_WEBHOOK_SECRET added to .env.example.

Verified end-to-end: a signed sample post delivered 200 and appeared in the
index, post page, RSS, and sitemap; build + typecheck pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-08 11:53:34 +00:00
parent ade606c157
commit cf99e93c53
13 changed files with 549 additions and 82 deletions

View file

@ -0,0 +1,34 @@
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
const url = process.env.SUPABASE_URL;
const anonKey = process.env.SUPABASE_ANON_KEY ?? process.env.SUPABASE_PUBLISHABLE_KEY;
const serviceKey = process.env.SUPABASE_SECRET_KEY;
// Public read client (anon key) — used by /blog, /blog/[slug], the RSS feed,
// and the sitemap. RLS limits it to published posts.
export function publicClient(): SupabaseClient {
if (!url || !anonKey) {
throw new Error("SUPABASE_URL and SUPABASE_ANON_KEY (or SUPABASE_PUBLISHABLE_KEY) are required");
}
return createClient(url, anonKey, { auth: { persistSession: false } });
}
// Privileged client (service-role key) — used by the blog webhook to upsert
// posts. Bypasses RLS, so keep it server-only.
export function serviceClient(): SupabaseClient {
if (!url || !serviceKey) {
throw new Error("SUPABASE_URL and SUPABASE_SECRET_KEY are required");
}
return createClient(url, serviceKey, { auth: { persistSession: false } });
}
export type BlogPost = {
slug: string;
title: string;
excerpt: string | null;
html: string;
tags: string[];
featured_image: { url?: string } | null;
published_at: string;
updated_at: string;
};