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,70 @@
import { publicClient } from "@/lib/supabase";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type PostRow = {
slug: string;
title: string;
excerpt: string | null;
published_at: string;
};
function baseUrl(): string {
return (process.env.PUBLIC_URL ?? "https://logicsrc.com").replace(/\/$/, "");
}
function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
// GET /blog/rss.xml — RSS 2.0 feed generated from published blog_posts.
export async function GET(): Promise<Response> {
const base = baseUrl();
const supabase = publicClient();
const { data } = await supabase
.from("blog_posts")
.select("slug, title, excerpt, published_at")
.eq("status", "published")
.order("published_at", { ascending: false })
.limit(50);
const posts = (data ?? []) as PostRow[];
const items = posts
.map((post) => {
const link = `${base}/blog/${post.slug}`;
return ` <item>
<title>${escapeXml(post.title)}</title>
<link>${escapeXml(link)}</link>
<guid>${escapeXml(link)}</guid>
<description>${escapeXml(post.excerpt ?? "")}</description>
<pubDate>${new Date(post.published_at).toUTCString()}</pubDate>
</item>`;
})
.join("\n");
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>LogicSRC Blog</title>
<link>${base}/blog</link>
<description>Project notes for LogicSRC OpenSpec standards, AgentSwarm, AgentByte, SDKs, MCP, and reference implementations.</description>
<language>en-us</language>
<atom:link href="${base}/blog/rss.xml" rel="self" type="application/rss+xml" />
${items}
</channel>
</rss>
`;
return new Response(xml, {
headers: {
"content-type": "application/rss+xml; charset=utf-8",
"cache-control": "public, max-age=300, s-maxage=300",
},
});
}