diff --git a/.env.example b/.env.example index 0443bd5..6ce789a 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,8 @@ UGIG_WEBHOOK_SECRET= SH1PT_API_URL= SH1PT_API_KEY= SH1PT_WEBHOOK_SECRET= + +# Shared secret for the blog-post ingestion webhook (no admin user; the +# webhook authenticates callers by this secret instead). Generate with: +# openssl rand -hex 32 +BLOG_WEBHOOK_SECRET= diff --git a/apps/logicsrc-web/package.json b/apps/logicsrc-web/package.json index 686091e..3512a17 100644 --- a/apps/logicsrc-web/package.json +++ b/apps/logicsrc-web/package.json @@ -12,6 +12,8 @@ "test:e2e": "playwright test" }, "dependencies": { + "@profullstack/autoblog": "github:profullstack/autoblog#75e54af", + "@supabase/supabase-js": "^2.105.4", "next": "16.2.6", "react": "19.2.0", "react-dom": "19.2.0" diff --git a/apps/logicsrc-web/public/blog/rss.xml b/apps/logicsrc-web/public/blog/rss.xml deleted file mode 100644 index 9e7e906..0000000 --- a/apps/logicsrc-web/public/blog/rss.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - LogicSRC Blog - https://logicsrc.com/blog - Project notes for LogicSRC OpenSpec standards, AgentSwarm, AgentByte, SDKs, MCP, and reference implementations. - en-us - - - LogicSRC OpenSpec Compatibility - https://logicsrc.com/openspec - https://logicsrc.com/openspec - LogicSRC adds an OpenSpec.dev comparison and compatibility mode for repo-local specs, proposals, tasks, and deltas. - Sat, 06 Jun 2026 00:00:00 GMT - - - LogicSRC Credential Sharing OpenSpec - https://logicsrc.com/credential-sharing - https://logicsrc.com/credential-sharing - LogicSRC adds a credential-sharing OpenSpec for .env, Doppler, Railway variables, GitHub Secrets, and future provider adapters. - Sat, 06 Jun 2026 00:00:00 GMT - - - diff --git a/apps/logicsrc-web/public/sitemap.xml b/apps/logicsrc-web/public/sitemap.xml deleted file mode 100644 index ada0b73..0000000 --- a/apps/logicsrc-web/public/sitemap.xml +++ /dev/null @@ -1,58 +0,0 @@ - - - - https://logicsrc.com/ - weekly - 1.0 - - - https://logicsrc.com/docs - weekly - 0.9 - - - https://logicsrc.com/openspec - weekly - 0.8 - - - https://logicsrc.com/agent-swarm - weekly - 0.8 - - - https://logicsrc.com/agentbyte - weekly - 0.8 - - - https://logicsrc.com/credential-sharing - weekly - 0.8 - - - https://logicsrc.com/hire-us - weekly - 0.8 - - - https://logicsrc.com/blog - weekly - 0.7 - - - https://logicsrc.com/about - monthly - 0.6 - - - https://logicsrc.com/terms - monthly - 0.4 - - - https://logicsrc.com/privacy - monthly - 0.4 - - diff --git a/apps/logicsrc-web/src/app/api/webhooks/blog/route.ts b/apps/logicsrc-web/src/app/api/webhooks/blog/route.ts new file mode 100644 index 0000000..074936d --- /dev/null +++ b/apps/logicsrc-web/src/app/api/webhooks/blog/route.ts @@ -0,0 +1,59 @@ +import type { NextRequest } from "next/server"; +import { verifyAndParse } from "@profullstack/autoblog"; +import { json } from "@/lib/http"; +import { serviceClient } from "@/lib/supabase"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// POST /api/webhooks/blog — receive an autoblog `post.published` webhook, +// verify the Standard Webhooks signature against BLOG_WEBHOOK_SECRET, and +// upsert the post into blog_posts. There is no admin user: the shared +// secret is the only credential, identical to the other Profullstack +// autoblog receivers. +export async function POST(request: NextRequest) { + const secret = process.env.BLOG_WEBHOOK_SECRET; + if (!secret) { + return json({ success: false, error: "Blog webhook is not configured" }, 503); + } + + // Raw bytes — the signature is computed over the body as received. + const body = await request.text(); + const headers: Record = {}; + request.headers.forEach((value, key) => { + headers[key] = value; + }); + + const result = verifyAndParse({ headers, body, opts: { secret } }); + if (!result.ok) { + return json({ success: false, error: result.reason }, result.status); + } + + const post = result.post; + const supabase = serviceClient(); + const { error } = await supabase.from("blog_posts").upsert( + { + external_id: post.id, + slug: post.slug, + title: post.title, + excerpt: post.excerpt ?? null, + html: post.html, + markdown: post.markdown ?? null, + url: post.url ?? null, + canonical_url: post.canonical_url ?? null, + author: post.author ?? null, + tags: post.tags ?? [], + categories: post.categories ?? [], + featured_image: post.featured_image ?? null, + status: post.status ?? "published", + published_at: post.published_at, + updated_at: post.updated_at, + }, + { onConflict: "slug" }, + ); + if (error) { + return json({ success: false, error: error.message }, 500); + } + + return json({ received: true, slug: post.slug }); +} diff --git a/apps/logicsrc-web/src/app/blog/[slug]/page.tsx b/apps/logicsrc-web/src/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..52656ed --- /dev/null +++ b/apps/logicsrc-web/src/app/blog/[slug]/page.tsx @@ -0,0 +1,96 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import type { ReactNode } from "react"; +import type { Metadata } from "next"; +import { publicClient } from "@/lib/supabase"; + +export const dynamic = "force-dynamic"; + +type PostRow = { + slug: string; + title: string; + excerpt: string | null; + html: string; + featured_image: { url?: string } | null; + published_at: string; +}; + +async function loadPost(slug: string): Promise { + const supabase = publicClient(); + const { data } = await supabase + .from("blog_posts") + .select("slug, title, excerpt, html, featured_image, published_at") + .eq("slug", slug) + .eq("status", "published") + .maybeSingle(); + return (data as PostRow | null) ?? null; +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + const post = await loadPost(slug); + if (!post) return { title: "Not found · LogicSRC" }; + return { + title: `${post.title} · LogicSRC`, + description: post.excerpt ?? undefined, + alternates: { canonical: `/blog/${post.slug}` }, + openGraph: { + title: post.title, + description: post.excerpt ?? undefined, + type: "article", + images: post.featured_image?.url ? [post.featured_image.url] : undefined, + }, + }; +} + +function formatDate(value: string): string { + return new Date(value).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +export default async function BlogPostPage({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + const post = await loadPost(slug); + if (!post) notFound(); + + return ( +
+

+ + ← Blog + +

+
+

+ {post.title} +

+
+ {formatDate(post.published_at)} +
+ {post.featured_image?.url ? ( + // eslint-disable-next-line @next/next/no-img-element + {post.title} + ) : null} +
+
+
+ ); +} diff --git a/apps/logicsrc-web/src/app/blog/page.tsx b/apps/logicsrc-web/src/app/blog/page.tsx new file mode 100644 index 0000000..c110816 --- /dev/null +++ b/apps/logicsrc-web/src/app/blog/page.tsx @@ -0,0 +1,84 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; +import type { Metadata } from "next"; +import { publicClient } from "@/lib/supabase"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Blog · LogicSRC", + description: + "Project notes for LogicSRC OpenSpec standards, AgentSwarm, AgentByte, SDKs, MCP, and reference implementations.", + alternates: { types: { "application/rss+xml": "/blog/rss.xml" } }, +}; + +type PostRow = { + slug: string; + title: string; + excerpt: string | null; + published_at: string; +}; + +function formatDate(value: string): string { + return new Date(value).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +export default async function BlogIndex(): Promise { + const supabase = publicClient(); + const { data } = await supabase + .from("blog_posts") + .select("slug, title, excerpt, published_at") + .eq("status", "published") + .order("published_at", { ascending: false }); + const posts = (data ?? []) as PostRow[]; + + return ( +
+

+ + ← LogicSRC + +

+

Blog

+

+ Project notes for LogicSRC OpenSpec standards, AgentSwarm, AgentByte, + SDKs, MCP, and reference implementations.{" "} + + RSS + +

+ + {posts.length === 0 ? ( +

No posts yet.

+ ) : ( +
    + {posts.map((post) => ( +
  • + +

    + {post.title} +

    + +
    + {formatDate(post.published_at)} +
    + {post.excerpt ? ( +

    {post.excerpt}

    + ) : null} +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/logicsrc-web/src/app/blog/rss.xml/route.ts b/apps/logicsrc-web/src/app/blog/rss.xml/route.ts new file mode 100644 index 0000000..d75c226 --- /dev/null +++ b/apps/logicsrc-web/src/app/blog/rss.xml/route.ts @@ -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, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// GET /blog/rss.xml — RSS 2.0 feed generated from published blog_posts. +export async function GET(): Promise { + 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 ` + ${escapeXml(post.title)} + ${escapeXml(link)} + ${escapeXml(link)} + ${escapeXml(post.excerpt ?? "")} + ${new Date(post.published_at).toUTCString()} + `; + }) + .join("\n"); + + const xml = ` + + + LogicSRC Blog + ${base}/blog + Project notes for LogicSRC OpenSpec standards, AgentSwarm, AgentByte, SDKs, MCP, and reference implementations. + en-us + +${items} + + +`; + + return new Response(xml, { + headers: { + "content-type": "application/rss+xml; charset=utf-8", + "cache-control": "public, max-age=300, s-maxage=300", + }, + }); +} diff --git a/apps/logicsrc-web/src/app/sitemap.ts b/apps/logicsrc-web/src/app/sitemap.ts new file mode 100644 index 0000000..9701a55 --- /dev/null +++ b/apps/logicsrc-web/src/app/sitemap.ts @@ -0,0 +1,58 @@ +import type { MetadataRoute } from "next"; +import { publicClient } from "@/lib/supabase"; + +export const dynamic = "force-dynamic"; + +function baseUrl(): string { + return (process.env.PUBLIC_URL ?? "https://logicsrc.com").replace(/\/$/, ""); +} + +// Static routes preserved from the legacy public/sitemap.xml. +const STATIC_ROUTES: Array<{ + path: string; + changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"]; + priority: number; +}> = [ + { path: "/", changeFrequency: "weekly", priority: 1.0 }, + { path: "/docs", changeFrequency: "weekly", priority: 0.9 }, + { path: "/openspec", changeFrequency: "weekly", priority: 0.8 }, + { path: "/agent-swarm", changeFrequency: "weekly", priority: 0.8 }, + { path: "/agentbyte", changeFrequency: "weekly", priority: 0.8 }, + { path: "/credential-sharing", changeFrequency: "weekly", priority: 0.8 }, + { path: "/hire-us", changeFrequency: "weekly", priority: 0.8 }, + { path: "/blog", changeFrequency: "daily", priority: 0.7 }, + { path: "/about", changeFrequency: "monthly", priority: 0.6 }, + { path: "/terms", changeFrequency: "monthly", priority: 0.4 }, + { path: "/privacy", changeFrequency: "monthly", priority: 0.4 }, +]; + +export default async function sitemap(): Promise { + const base = baseUrl(); + + const staticEntries: MetadataRoute.Sitemap = STATIC_ROUTES.map((route) => ({ + url: `${base}${route.path}`, + changeFrequency: route.changeFrequency, + priority: route.priority, + })); + + let postEntries: MetadataRoute.Sitemap = []; + try { + const supabase = publicClient(); + const { data } = await supabase + .from("blog_posts") + .select("slug, published_at, updated_at") + .eq("status", "published") + .order("published_at", { ascending: false }); + postEntries = (data ?? []).map((post) => ({ + url: `${base}/blog/${post.slug}`, + lastModified: new Date(post.updated_at ?? post.published_at), + changeFrequency: "weekly", + priority: 0.6, + })); + } catch { + // If the DB is unreachable, still serve the static sitemap. + postEntries = []; + } + + return [...staticEntries, ...postEntries]; +} diff --git a/apps/logicsrc-web/src/lib/supabase.ts b/apps/logicsrc-web/src/lib/supabase.ts new file mode 100644 index 0000000..60bcddb --- /dev/null +++ b/apps/logicsrc-web/src/lib/supabase.ts @@ -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; +}; diff --git a/package-lock.json b/package-lock.json index 7ec763e..ce6e231 100644 --- a/package-lock.json +++ b/package-lock.json @@ -126,6 +126,8 @@ "name": "@logicsrc/web", "version": "0.1.0", "dependencies": { + "@profullstack/autoblog": "github:profullstack/autoblog#75e54af", + "@supabase/supabase-js": "^2.105.4", "next": "16.2.6", "react": "19.2.0", "react-dom": "19.2.0" @@ -1701,6 +1703,14 @@ "node": ">=18" } }, + "node_modules/@profullstack/autoblog": { + "version": "0.4.0", + "resolved": "git+ssh://git@github.com/profullstack/autoblog.git#75e54af77cbcf61dbd90fb3ed529c1b2dad1ed6f", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@profullstack/logicsrc-mcp": { "resolved": "packages/logicsrc-mcp", "link": true @@ -2342,6 +2352,90 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.107.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.107.0.tgz", + "integrity": "sha512-XA7x+WIeIvuC3GTZ2ey67QcBbGw4n+o5B7M+dMm9KT1lL3wX1B52DfEWW00WuPt/LnniJLLIn1WIm9YPtuxzKQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.107.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.107.0.tgz", + "integrity": "sha512-iMtRUmEj1KOgQd/a3MR4hnBlPnZc62DW8+z8aPpnzbxWkexEZUVL2fSgvvp15gqFg1V55e2yMGqgK+yhSQxp5w==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz", + "integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.107.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.107.0.tgz", + "integrity": "sha512-7ARs47/tyIjX7T0Ive20d4NY8zQYXsP5/P07jJWxffSIM2gpnSnGRnL/Fe15GPbdjsW2sTYeckHcyaoKbM6yWQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.107.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.107.0.tgz", + "integrity": "sha512-cF2KYdR3JIn9YlWGeluY9S0G+otqTdL6hB8GzpatlEIY6fZudCcyFo6Dc3+X9tjeb+x9XcIyNAk9qhNAknjH1A==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.2", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.107.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.107.0.tgz", + "integrity": "sha512-/X8OOVwKBn8aVKuHAGOz2yLA0d2OauqhVuy4mNtN+o7wttHOgx1/j+pqOzlsjmhOHrYykF6AJNZhs3gKZzcMUw==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.107.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.107.0.tgz", + "integrity": "sha512-ChKzdlWVweMUUhr0U79JhMmgm1haS/C5JquaiCDr70JaGARRtjjoY9rkIheXWybXxTSNzRiQs3Sk8IAg1HS3ZA==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.107.0", + "@supabase/functions-js": "2.107.0", + "@supabase/postgrest-js": "2.107.0", + "@supabase/realtime-js": "2.107.0", + "@supabase/storage-js": "2.107.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -3367,6 +3461,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..2e04c41 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1 @@ +project_id = "qgytusjwuvlynkdknype" diff --git a/supabase/migrations/20260608020000_blog_posts.sql b/supabase/migrations/20260608020000_blog_posts.sql new file mode 100644 index 0000000..f9c28b6 --- /dev/null +++ b/supabase/migrations/20260608020000_blog_posts.sql @@ -0,0 +1,37 @@ +-- Blog posts ingested via the autoblog webhook (/api/webhooks/blog). +-- Source of truth for /blog, /blog/[slug], /blog/rss.xml, and sitemap.xml. +-- Writes happen only through the service-role key (the webhook); the public +-- (anon) key can read published posts. + +create table if not exists public.blog_posts ( + id uuid primary key default gen_random_uuid(), + external_id text unique, -- autoblog Post.id (idempotency) + slug text not null unique, + title text not null, + excerpt text, + html text not null, + markdown text, + url text, + canonical_url text, + author jsonb, + tags text[] not null default '{}', + categories text[] not null default '{}', + featured_image jsonb, + status text not null default 'published', + published_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + created_at timestamptz not null default now() +); + +create index if not exists blog_posts_published_idx + on public.blog_posts (published_at desc) + where status = 'published'; + +alter table public.blog_posts enable row level security; + +-- Public can read published posts; everything else is service-role only +-- (service_role bypasses RLS, so no insert/update policy is needed). +drop policy if exists "blog_posts public read" on public.blog_posts; +create policy "blog_posts public read" + on public.blog_posts for select + using (status = 'published');