Install Blogged Lovable receiver 1.0.0 in this existing app using the attached versioned receiver package and README. Read the package, do not invent its protocol. Before editing, confirm this app has TanStack Start server rendering and a Cloud or Supabase backend. If it is Vite-only, STOP and explain the compatibility limit; do not upgrade frameworks, migrate backends, or rewrite the app. Checkpoint the app. Keep all existing routes, layout, auth, forms, data and policies. Use only isolated receiver tables/functions and unused blog paths. If a blog already exists, stop for an explicit coexistence decision. Install the supplied receiver files and additive SQL exactly; verify their manifest hashes. Add the supplied server routes and adapt only presentation to this site's design. Use ordinary anchors for the raw server-rendered blog pages; include the dynamic sitemap in existing discovery configuration. Do not replace existing sitemap/robots rules. For new TanStack Lovable Cloud projects, use the native server-route examples: copy lib/receiver.mjs, lib/pages.mjs and their .d.mts type declarations to src/lib/blogged/, vendor/protocol.mjs to src/lib/vendor/, blogged-server.ts to src/lib/, and all route examples to src/routes/. Create the complete module set before checking the build; never leave route imports pointing at missing modules. The native wrapper reads environment per request and uses getRequest().waitUntil with a 25-second budget. If that hook is absent, fail closed; never replace it with fire-and-forget or synchronous publishing. New TanStack Cloud projects block creating Edge Functions. Do not bypass that rule. The Edge Function entry is optional for separately supported Supabase deployments. Ask for BLOGGED_SIGNING_SECRET only through the backend secrets form, never chat. Also configure BLOGGED_PROJECT_ID, BLOGGED_PUBLIC_BLOG_URL and BLOGGED_RECEIVER_URL. Use the backend's existing SUPABASE_URL and server-only SUPABASE_SERVICE_ROLE_KEY. Never expose service credentials in public code. The native /api/public/blogged-receiver route enforces Blogged HMAC itself. For an optional Supabase Edge Function deployment, disable platform JWT verification only on blogged-receiver, which verifies Blogged's HMAC itself. Do not weaken any other endpoint. Keep the receiver's public reads limited to published content. Take the public hostname from the Publish panel, not a guessed project-ID hostname. Return the actual deployed receiver endpoint, public blog URL, installed version, and exact changed files. Verify the homepage and every pre-existing route still work. Publish app-code changes only with the owner's permission. Do not create real articles or modify Blogged routing. Explain that users finish connection verification and explicitly save routing in Blogged; ordinary publishing afterward uses data updates without another Lovable prompt or website rebuild. ===== FILE: INSTALL.md ===== Install Blogged Lovable receiver 1.0.0 in this existing app using the attached versioned receiver package and README. Read the package, do not invent its protocol. Before editing, confirm this app has TanStack Start server rendering and a Cloud or Supabase backend. If it is Vite-only, STOP and explain the compatibility limit; do not upgrade frameworks, migrate backends, or rewrite the app. Checkpoint the app. Keep all existing routes, layout, auth, forms, data and policies. Use only isolated receiver tables/functions and unused blog paths. If a blog already exists, stop for an explicit coexistence decision. Install the supplied receiver files and additive SQL exactly; verify their manifest hashes. Add the supplied server routes and adapt only presentation to this site's design. Use ordinary anchors for the raw server-rendered blog pages; include the dynamic sitemap in existing discovery configuration. Do not replace existing sitemap/robots rules. For new TanStack Lovable Cloud projects, use the native server-route examples: copy lib/receiver.mjs, lib/pages.mjs and their .d.mts type declarations to src/lib/blogged/, vendor/protocol.mjs to src/lib/vendor/, blogged-server.ts to src/lib/, and all route examples to src/routes/. Create the complete module set before checking the build; never leave route imports pointing at missing modules. The native wrapper reads environment per request and uses getRequest().waitUntil with a 25-second budget. If that hook is absent, fail closed; never replace it with fire-and-forget or synchronous publishing. New TanStack Cloud projects block creating Edge Functions. Do not bypass that rule. The Edge Function entry is optional for separately supported Supabase deployments. Ask for BLOGGED_SIGNING_SECRET only through the backend secrets form, never chat. Also configure BLOGGED_PROJECT_ID, BLOGGED_PUBLIC_BLOG_URL and BLOGGED_RECEIVER_URL. Use the backend's existing SUPABASE_URL and server-only SUPABASE_SERVICE_ROLE_KEY. Never expose service credentials in public code. The native /api/public/blogged-receiver route enforces Blogged HMAC itself. For an optional Supabase Edge Function deployment, disable platform JWT verification only on blogged-receiver, which verifies Blogged's HMAC itself. Do not weaken any other endpoint. Keep the receiver's public reads limited to published content. Take the public hostname from the Publish panel, not a guessed project-ID hostname. Return the actual deployed receiver endpoint, public blog URL, installed version, and exact changed files. Verify the homepage and every pre-existing route still work. Publish app-code changes only with the owner's permission. Do not create real articles or modify Blogged routing. Explain that users finish connection verification and explicitly save routing in Blogged; ordinary publishing afterward uses data updates without another Lovable prompt or website rebuild. ===== FILE: README.md ===== # Blogged receiver for Lovable — 1.0.0 This receiver installs in a customer's Lovable Cloud or Supabase backend. Blogged continues to own editorial operations. Your app stores and serves the approved article and copied images. It uses the existing Blogged signed-webhook v1 protocol; no Lovable OAuth grant, full-account access, or per-article AI request is needed. ## Requirements - A published app with **server-rendered blog routes**. The included TanStack Start server routes work without JavaScript. Vite-only routes are not supported by Blogged's current public-page verifier. Never migrate an existing app as an automatic part of this installation. - A Lovable Cloud or Supabase backend with PostgreSQL and an server runtime supporting Node compatibility and pinned parse5. New TanStack Cloud projects use the request's real `waitUntil` hook; a separately supported Supabase Edge Function may use `EdgeRuntime.waitUntil`. New TanStack Cloud projects prohibit creating Edge Functions; use the native server adapter instead. - Permission to add isolated tables/functions and server-only secrets. Public blog routes must not require a user login. Existing app authentication stays intact. - Space for at most 20 images per article, 5 MiB each. This small receiver stores bytes in its isolated PostgreSQL tables. Database size, backups, hosting and execution usage belong to the customer. Monitor capacity; do not assume free hosting allowances are sufficient for a growing blog. ## Install in an existing project 1. Back up/checkpoint the app. Check that the `blogged_receiver` schema, `blogged_receiver_dispatch` function, and desired blog paths are unused. If a blog already exists, stop and plan explicit coexistence; never reuse/overwrite existing posts by matching their slug. 2. Apply `supabase/migrations/202609120001_blogged_receiver.sql`. It adds an isolated schema with RLS and one service-role-only RPC; it changes no existing tables or authentication policies. Reapplying this version is safe for this receiver's schema. Do not modify it into a destructive migration. 3. For TanStack Cloud, copy `lib/receiver.mjs` and `lib/pages.mjs` into `src/lib/blogged/` together with their `.d.mts` type declarations, `vendor/protocol.mjs` into `src/lib/vendor/`, and the examples' `blogged-server.ts` into `src/lib/`. Copy the four route files into `src/routes/`. Install parse5 8.0.1. Complete all referenced modules before building. The native endpoint is `/api/public/blogged-receiver`; writes still require HMAC. It reads secrets inside the request and refuses publication if `getRequest().waitUntil` is absent. Never replace that hook with untracked background work. For a separately supported Supabase Edge deployment, the ZIP also includes a self-contained `supabase/functions/blogged-receiver/` directory. Merge only its `verify_jwt = false` setting; all other JWT settings remain unchanged. 4. Add `BLOGGED_PROJECT_ID`, `BLOGGED_SIGNING_SECRET` (at least 32 random characters), `BLOGGED_PUBLIC_BLOG_URL` (e.g. `https://example.com/blog`), and `BLOGGED_RECEIVER_URL` (the **actual deployed function URL**) through the backend secret form. Confirm the public hostname in Lovable’s Publish panel rather than assuming it from the project ID. Keep `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` in the backend's provided environment. Never paste private values into chat, frontend files, URLs, version control, or logs. Function URL and public blog URL are public. 5. The native page adapter reads published data directly on the server and avoids a Worker-to-itself network request. Link to these raw HTTP server routes with ordinary anchors so an SPA router does not intercept navigation. Adapt styling without changing receiver semantics or removing metadata. For a different blog path, update route paths/filenames and the configured public URL together. Preserve existing routes, root layout, authentication, forms and policies. 6. Link the blog from an existing navigation/footer. Merge the blog sitemap URL into the site's existing robots/sitemap configuration; do not replace it. Publish the app once. Article changes thereafter update data, not app code. 7. In Blogged select the Lovable setup (signed-webhook transport), enter the public blog URL, receiver URL and matching secret. Verify, then explicitly save routing for new articles. No old articles are migrated. Keep the default Blogged summary and link if desired. Subsequent edits synchronize only through the existing saved-version update action. Connection verification first checks rendered blog HTML, then binds this installation to one Blogged Project and one public blog URL, exercises database writes inside a rolled-back probe, and checks for the server-rendered blog marker. It does not publish a demonstration article. A changed Project or public URL requires an explicit migration/separate installation, not an accidental reconnect. A backend must not be shared between preview and production receiver installations with different bindings. ## Reliability and ownership HMAC signs the timestamp and exact JSON bytes. Requests have a five-minute freshness window, a 1,000,000-byte body cap, identity validation and ordered revisions. Add normal host rate protection to the receiver. An HTTP 202 means work was stored; only a durable completed receipt and Blogged's independent reader check establish publication. Images are downloaded only from the fixed Blogged transfer origin, without redirects, and verified against their hash/type before becoming visible. Processing uses a 25-second budget on native Cloud routes (45 seconds for the optional Edge adapter), with 90-second database leases and per-step timeouts bounded by the remaining budget. Blogged's existing redelivery resumes incomplete work and refreshes expired image grants. No progress depends on local disk or a background promise surviving indefinitely. Do not add an unbounded recurring poller. Failed/paused hosting or exhausted storage leaves delivery needing retry/attention; it does not change Blogged's draft approval. The first permalink is preserved with a publication-ID suffix. Withdrawals win against older work and remove the public article, sitemap entry and asset access. HTTP responses use no-store; if adding a CDN or cache, prove invalidation before claiming withdrawal. Previously downloaded content cannot be recalled. Tombstones and delivery receipts must remain to prevent replay. Retained unused images and private job data need an explicit capacity/retention policy; never delete shared assets or unconfirmed jobs automatically. No separate CMS editor is installed. If someone edits receiver rows directly, all rendering changes must atomically update remote_revision under the same row lock. Ordinary Lovable site design edits should not touch managed article rows. Use Blogged's Unpublish and disconnect flow before uninstalling a live connection. The customer owns the copied content; uninstall/delete is a separate deliberate operation after backup, including only this receiver's namespace and routes. ## Tests and release integrity `npm ci --ignore-scripts && npm test` runs real PostgreSQL engine tests in memory using PGlite. CI repeats them against PostgreSQL with a loopback-only database named `blogged_lovable_fixture` through `BLOGGED_LOVABLE_TEST_DATABASE_URL`. The test runner must refuse other database names and non-loopback hosts. `manifest.json` lists SHA-256 hashes of the distributed files. Installation should copy the versioned source; do not ask the AI to invent authentication or concurrency logic. `vendor/protocol.mjs` is a frozen protocol snapshot from the existing receiver at 74f838d0 with an explicit Buffer import for Deno; changes to it cannot alter existing Next.js installations. Sitemap pages are bounded at 100 records and 100,100 published records total. Above that capacity the sitemap fails explicitly instead of silently omitting articles; plan a receiver pagination upgrade before reaching it. A very slow host can exhaust Blogged's bounded retry window; completed image copies remain durable for an explicit retry. Existing routing/limits are not expanded to compensate for host failures. Backend credentials may be legacy service-role JWTs or modern opaque secret keys. The RPC client uses `apikey` for both and adds bearer authorization only for JWTs, following [Supabase's key migration guidance](https://supabase.com/docs/guides/getting-started/migrating-to-new-api-keys). Keep these credentials server-only; they are never needed in Blogged's form. Outbound requests use manual redirect handling and reject non-success responses; no redirect target receives backend credentials, signatures or media grants. ===== FILE: examples/tanstack/api.public.blogged-receiver.ts ===== import { createFileRoute } from "@tanstack/react-router"; import { handleBlogged } from "@/lib/blogged-server"; export const Route = createFileRoute("/api/public/blogged-receiver")({ server: { handlers: { GET: ({ request }) => handleBlogged(request), HEAD: ({ request }) => handleBlogged(request), POST: ({ request }) => handleBlogged(request), }, }, }); ===== FILE: examples/tanstack/blog.$slug.ts ===== import { createFileRoute } from "@tanstack/react-router"; import { getBloggedPages } from "@/lib/blogged-server"; export const Route = createFileRoute("/blog/$slug")({ server: { handlers: { GET: ({ params }) => getBloggedPages().article(params.slug) } }, }); ===== FILE: examples/tanstack/blog.sitemap[.]xml.ts ===== import { createFileRoute } from "@tanstack/react-router"; import { getBloggedPages } from "@/lib/blogged-server"; export const Route = createFileRoute("/blog/sitemap.xml")({ server: { handlers: { GET: ({ request }) => getBloggedPages().sitemap(request) } }, }); ===== FILE: examples/tanstack/blog.ts ===== import { createFileRoute } from "@tanstack/react-router"; import { getBloggedPages } from "@/lib/blogged-server"; export const Route = createFileRoute("/blog")({ server: { handlers: { GET: ({ request }) => getBloggedPages().index(request) } }, }); ===== FILE: examples/tanstack/blogged-server.ts ===== // Copy to src/lib/blogged-server.ts. Import only from server-route handlers. import { getRequest } from "@tanstack/react-start/server"; import { configFrom, createHandler } from "@/lib/blogged/receiver.mjs"; import { publicPages } from "@/lib/blogged/pages.mjs"; type BackgroundRequest = Request & { waitUntil?: (promise: Promise) => void }; export function handleBlogged(request: Request) { // Lovable injects the server environment per request, not at module load. const config = configFrom(process.env); const runtimeRequest = getRequest() as BackgroundRequest; return createHandler(config, { processBudgetMs: 25000, ...(typeof runtimeRequest.waitUntil === "function" ? { waitUntil: (promise: Promise) => runtimeRequest.waitUntil!(promise) } : {}), })(request); } export function getBloggedPages() { const config = configFrom(process.env); const publicRead = createHandler(config); return publicPages({ endpoint: config.endpoint, publicBase: config.publicBase, // Avoid an unnecessary Worker-to-itself network fetch. Only GET public-read // operations run here; the service role remains inside this server module. fetcher: (url: string) => publicRead(new Request(url)), }); } ===== FILE: lib/pages.d.mts ===== export function publicPages(options: { endpoint: string; publicBase: string; fetcher?: (url: string, options?: RequestInit) => Promise; }): { index(request: Request): Promise; article(slug: string): Promise; sitemap(request: Request): Promise; }; ===== FILE: lib/pages.mjs ===== // Only public content is fetched here. This module needs no API key or database credential. const escape = (value) => String(value ?? "").replace( /[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], ); const headers = { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", }; export function publicPages({ endpoint, publicBase, fetcher = fetch }) { const e = new URL(endpoint), b = new URL(publicBase); if ( e.protocol !== "https:" || b.protocol !== "https:" || e.username || e.password || e.search || e.hash || b.username || b.password || b.search || b.hash ) throw new Error("Configure exact public HTTPS URLs."); const base = publicBase.replace(/\/$/, ""); async function read(query) { const response = await fetcher(`${endpoint}?${new URLSearchParams(query)}`, { redirect: "manual", signal: AbortSignal.timeout(15000), cache: "no-store", }); if (response.status === 404) return null; if (!response.ok) throw new Error("Blog content unavailable"); return response.json(); } const shell = (title, description, canonical, body, extra = "") => `${escape(title)}${extra}${body}`; return { async index(request) { try { const url = new URL(request.url), offset = Number(url.searchParams.get("offset") || 0); if (!Number.isSafeInteger(offset) || offset < 0 || offset > 100000) return new Response("Invalid page", { status: 400, headers }); const rows = await read({ list: "1", offset: String(offset) }); const list = rows .map( (p) => ``, ) .join(""); return new Response( shell( "Blog", "Articles", offset ? `${base}?offset=${offset}` : base, `

Blog

${list || "

No articles published yet.

"}${rows.length === 100 && offset < 100000 ? `Older articles` : ""}
`, ), { headers }, ); } catch { return new Response("Blog temporarily unavailable", { status: 503, headers }); } }, async article(slug) { if (!/^[a-z0-9-]{1,160}$/.test(slug || "")) return new Response("Not found", { status: 404, headers }); try { const row = await read({ slug }); if (!row) return new Response("Not found", { status: 404, headers }); const a = row.article, canonical = a.noindex && a.primaryUrl ? a.primaryUrl : `${base}/${row.slug}`; const structured = JSON.stringify({ "@context": "https://schema.org", "@type": "BlogPosting", headline: a.title, datePublished: row.published_at, dateModified: row.updated_at, mainEntityOfPage: canonical, ...(a.coverImageUrl ? { image: a.coverImageUrl } : {}), }).replace(/${a.coverImageUrl ? `` : ""}`; return new Response( shell( a.seoTitle || a.title, a.metaDescription, canonical, `

${escape(a.title)}

${a.coverImageUrl ? `${escape(a.coverImageAlt || a.title)}` : ""}${a.html}
`, extra, ), { headers }, ); } catch { return new Response("Article temporarily unavailable", { status: 503, headers }); } }, async sitemap(request) { try { const page = Number(new URL(request.url).searchParams.get("page") || 0); if (!Number.isSafeInteger(page) || page < 0 || page > 1000) return new Response("Invalid page", { status: 400, headers }); const rows = await read({ list: "1", offset: String(page * 100) }); // Each page is independently bounded; a sitemap index links every nonempty page. if (new URL(request.url).searchParams.has("page")) { const xml = rows .filter((p) => p.noindex !== "true") .map( (p) => `${escape(base + "/" + p.slug)}${escape(p.updated_at)}`, ) .join(""); return new Response( `${xml}`, { headers: { ...headers, "Content-Type": "application/xml" } }, ); } // An index count comes from the receiver; avoid unbounded list scans per request. const count = await read({ count: "1" }); if (!Number.isSafeInteger(count) || count < 0 || count > 100100) throw new Error("Receiver sitemap capacity exceeded"); const pages = Math.max(1, Math.ceil(count / 100)); const xml = Array.from( { length: Math.min(pages, 1001) }, (_, i) => `${escape(base + "/sitemap.xml?page=" + i)}`, ).join(""); return new Response( `${xml}`, { headers: { ...headers, "Content-Type": "application/xml" } }, ); } catch { return new Response("Sitemap temporarily unavailable", { status: 503, headers }); } }, }; } ===== FILE: lib/receiver.d.mts ===== export interface ReceiverConfig { secret: string; projectId: string; publicBase: string; endpoint: string; supabaseUrl: string; serviceKey: string; apiOrigin: string; } export type ReceiverDatabase = ( mode: string, args?: Record, options?: { timeoutMs?: number }, ) => Promise; export function configFrom( env: Record | { get(name: string): string | undefined }, ): ReceiverConfig; export function database(config: ReceiverConfig, fetcher?: typeof fetch): ReceiverDatabase; export function processDelivery( db: ReceiverDatabase, id: string, config: ReceiverConfig, options?: { fetcher?: typeof fetch; budgetMs?: number }, ): Promise; export function createHandler( config: ReceiverConfig, options?: { db?: ReceiverDatabase; fetcher?: typeof fetch; waitUntil?: (promise: Promise) => void; processBudgetMs?: number; }, ): (request: Request) => Promise; ===== FILE: lib/receiver.mjs ===== import { Buffer } from "node:buffer"; import { randomUUID } from "node:crypto"; import { authenticate, fail, hashBytes, isId, readBounded, renderArticleHtml, semanticHash, signature, validateDelivery, } from "../vendor/protocol.mjs"; export function configFrom(env) { const value = (key) => (typeof env.get === "function" ? env.get(key) : env[key]); const secret = value("BLOGGED_SIGNING_SECRET"); const projectId = value("BLOGGED_PROJECT_ID"); const publicBase = value("BLOGGED_PUBLIC_BLOG_URL") || ""; const endpoint = value("BLOGGED_RECEIVER_URL") || ""; const supabaseUrl = value("SUPABASE_URL") || ""; const serviceKey = value("SUPABASE_SERVICE_ROLE_KEY"); for (const raw of [publicBase, endpoint, supabaseUrl]) { let u; try { u = new URL(raw); } catch { fail("Complete receiver server configuration.", 503); } if ( u.protocol !== "https:" || u.username || u.password || u.search || u.hash || u.port || u.hostname === "localhost" || u.hostname.endsWith(".local") || /^[\d.]+$/.test(u.hostname) || u.hostname.includes(":") ) fail("Use public HTTPS receiver configuration.", 503); } if ( !secret || secret.length < 32 || !/^[A-Za-z0-9_-]{1,128}$/.test(projectId || "") || !serviceKey || !/^\/[a-z0-9][a-z0-9/-]*$/.test(new URL(publicBase).pathname.replace(/\/$/, "")) ) fail("Complete receiver server configuration.", 503); return { secret, projectId, publicBase: publicBase.replace(/\/$/, ""), endpoint: endpoint.replace(/\/$/, ""), supabaseUrl: supabaseUrl.replace(/\/$/, ""), serviceKey, apiOrigin: "https://app.blogged.dev", }; } export function database(config, fetcher = fetch) { return async (mode, args = {}, { timeoutMs = 15000 } = {}) => { const response = await fetcher(`${config.supabaseUrl}/rest/v1/rpc/blogged_receiver_dispatch`, { method: "POST", redirect: "manual", signal: AbortSignal.timeout(Math.max(1, Math.min(15000, timeoutMs))), headers: { "Content-Type": "application/json", apikey: config.serviceKey, // Modern opaque keys authenticate through apikey, not JWT bearer auth. ...(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(config.serviceKey) ? { Authorization: `Bearer ${config.serviceKey}` } : {}), }, body: JSON.stringify({ mode, args: { ...args, projectId: config.projectId, publicBase: config.publicBase }, }), }); if (!response.ok) fail( response.status === 409 ? "Review the receiver binding or remote article revision." : "Receiver database unavailable.", response.status === 409 ? 409 : 503, ); return JSON.parse((await readBounded(response.body, 8 * 1024 * 1024)).toString("utf8")); }; } const accepted = (body) => ({ version: 1, publicationId: body.publicationId, revision: body.revision, state: "accepted", }); const json = (body, status = 200) => Response.json(body, { status, headers: { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" }, }); function imageType(bytes) { if (bytes.length < 12 || bytes.length > 5 * 1024 * 1024) return null; if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return "image/png"; if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return "image/jpeg"; if (bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") return "image/webp"; if (["GIF87a", "GIF89a"].includes(bytes.toString("ascii", 0, 6))) return "image/gif"; return null; } export async function processDelivery(db, id, config, { fetcher = fetch, budgetMs = 45000 } = {}) { const token = randomUUID(), deadline = Date.now() + Math.min(45000, budgetMs); const originalDb = db; const remaining = () => Math.max(1, Math.min(5000, deadline - Date.now())); db = (mode, args) => originalDb(mode, args, { timeoutMs: remaining() }); const claim = await db("claim", { deliveryId: id, token }); if (!claim) return; let receipt = claim.receipt; if (!receipt) { const body = validateDelivery(claim.body, config); try { for (const asset of body.article.assets) { if (Date.now() + 5000 > deadline) return; if (await db("asset_exists", { id: asset.id })) continue; const response = await fetcher(asset.url, { redirect: "manual", signal: AbortSignal.timeout(remaining()), cache: "no-store", }); if (!response.ok) fail("Image download needs another attempt.", 503); const bytes = await readBounded(response.body, 5 * 1024 * 1024); if (hashBytes(bytes) !== asset.id || imageType(bytes) !== asset.contentType) fail("Image does not match its signed manifest.", 400); if ( !(await db("asset_store", { deliveryId: id, token, id: asset.id, contentType: asset.contentType, base64: bytes.toString("base64"), })) ) return; } const media = new Map( body.article.assets.map((a) => [a.url, `${config.endpoint}?asset=${a.id}`]), ); const article = { ...body.article, html: renderArticleHtml(body.article.html, media), coverImageUrl: media.get(body.article.coverImageUrl) || null, assets: body.article.assets.map((a) => ({ id: a.id, url: media.get(a.url), alt: a.alt || "", })), }; if (Date.now() + 1000 > deadline) return; receipt = await db("finish", { deliveryId: id, token, article }); } finally { await db("release", { deliveryId: id, token }); } } if (receipt && Date.now() < deadline) { const raw = JSON.stringify(receipt), timestamp = String(Math.floor(Date.now() / 1000)); const response = await fetcher(`${config.apiOrigin}/api/integrations/receipts`, { method: "POST", redirect: "manual", signal: AbortSignal.timeout(remaining()), headers: { "Content-Type": "application/json", "X-Blogged-Timestamp": timestamp, "X-Blogged-Signature": signature(config.secret, timestamp, raw), }, body: raw, }); if (!response.ok && response.status !== 409) fail("Receipt will be replayed on redelivery.", 503); } } /** HMAC writes and explicit public read actions share an endpoint; no arbitrary RPC forwarding. */ export function createHandler( config, { db = database(config), fetcher = fetch, waitUntil, processBudgetMs = 45000 } = {}, ) { if (!Number.isSafeInteger(processBudgetMs) || processBudgetMs < 5000 || processBudgetMs > 45000) fail("Invalid receiver processing budget.", 503); return async (request) => { try { const url = new URL(request.url); if (request.method === "GET" || request.method === "HEAD") { let response; if (url.searchParams.has("asset")) { const id = url.searchParams.get("asset"); if (!isId(id)) return json({ error: "Not found" }, 404); const asset = await db("public_asset", { id }); if (!asset) return json({ error: "Not found" }, 404); response = new Response(Buffer.from(asset.base64, "base64"), { headers: { "Content-Type": asset.contentType, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", }, }); } else if (url.searchParams.has("slug")) { const slug = url.searchParams.get("slug"); if (!/^[a-z0-9-]{1,160}$/.test(slug || "")) return json({ error: "Not found" }, 404); const row = await db("public_post", { slug }); response = row ? json(row) : json({ error: "Not found" }, 404); } else if (url.searchParams.get("count") === "1") { response = json(await db("public_count")); } else if (url.searchParams.get("list") === "1") { const offset = Number(url.searchParams.get("offset") || 0); if (!Number.isSafeInteger(offset) || offset < 0 || offset > 100000) return json({ error: "Invalid page" }, 400); response = json(await db("public_list", { offset })); } else return json({ error: "Not found" }, 404); return request.method === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response; } if (request.method !== "POST") return json({ error: "Method not allowed" }, 405); if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) return json({ error: "Expected JSON" }, 415); const raw = (await readBounded(request.body, 1000000)).toString("utf8"); const body = authenticate( raw, request.headers.get("x-blogged-timestamp"), request.headers.get("x-blogged-signature"), config.secret, ); if (body.event === "connection.test") { if (body.siteId !== config.projectId) fail("Receiver belongs to another Blogged Project.", 403); if (typeof waitUntil !== "function") fail("Durable background invocation support is required.", 503); const page = await fetcher(config.publicBase, { redirect: "manual", signal: AbortSignal.timeout(15000), cache: "no-store", }); const html = (await readBounded(page.body, 1000000)).toString("utf8"); if ( !page.ok || !page.headers.get("content-type")?.includes("text/html") || !html.includes('data-blogged-receiver="1"') ) fail( "Publish the server-rendered Blogged blog routes before verifying. Vite-only shells are not supported.", 422, ); await db("probe"); return json({ version: 1, receiver: "blogged-lovable", receiverVersion: "1.0.0", canPublish: true, canUpdate: true, canWithdraw: true, copiesImages: true, canNoindex: true, canCompareRevision: true, idempotent: true, orderedRevisions: true, }); } if (body.event === "article.inspect") { if (!isId(body.publicationId)) fail("Invalid publication ID.", 400); const row = await db("inspect", { id: body.publicationId }); return json({ version: 1, publicationId: body.publicationId, found: !!row, ...(row ? { record: { id: row.id, revision: row.remote_revision || "", url: row.state === "published" ? `${config.publicBase}/${row.slug}` : null, state: row.state, }, article: row.article || { title: "", html: "", summary: "", seoTitle: "", metaDescription: "", }, } : {}), }); } validateDelivery(body, config); if (typeof waitUntil !== "function") fail("Background invocation support is required.", 503); const receipt = await db("receive", { body, semantic: semanticHash(body) }); if (!receipt) waitUntil( processDelivery(db, body.deliveryId, config, { fetcher, budgetMs: processBudgetMs, }).catch(() => { console.error("Blogged receiver delivery needs redelivery or operator review."); }), ); return json(receipt || accepted(body), receipt ? 200 : 202); } catch (error) { return json( { error: error.status && error.status < 500 ? error.message : "Receiver temporarily unavailable.", }, error.status || 503, ); } }; } ===== FILE: manifest.json ===== { "version": "1.0.0", "files": { "INSTALL.md": "650508398e492aa85a346ffffe4e3c05d85af6bd3c07214884b83ebd633be187", "README.md": "8446ff36eda6da8d2fd1131ca7f8a08a2d58a2294d7a396a3cb89dd849f2b1d5", "examples/tanstack/api.public.blogged-receiver.ts": "3c3b5345928ab201312fae49ad1bcb888b2e05e4e4ebe274dcf35b4e07bf9267", "examples/tanstack/blog.$slug.ts": "39d8cdaf7a2f26fb2057d1c1e7315b0c332b1309570c607905f1427b2165f497", "examples/tanstack/blog.sitemap[.]xml.ts": "6f622d0375f30bf28958ee4d6b27186f73c14b3756cdb38b161a2ea434058741", "examples/tanstack/blog.ts": "890466f8f49183fb7872ff555ae538f44ce2088a92dc67ede6ad5de54fe87610", "examples/tanstack/blogged-server.ts": "ac3d513d396d45d1407ff1f9bd2e25d038bd6bf97382fac2c9ff3b5b9f460de1", "lib/pages.d.mts": "4f7d2aa14075fe7455e46dd6790e912096ce7a647ab675f5c147599891283d72", "lib/pages.mjs": "ac0926a2c46fd189e1ef481bed6bb9d8813e95a215561d9a47f5c5bcadd7f3e5", "lib/receiver.d.mts": "f0d524f2b1f972819f33bcf95b6a4bbd0da537ed82c97ea2f351c72972d92fff", "lib/receiver.mjs": "501eca762bf8084292715e3f48761d851073029003d9c475abf2557691115daf", "package-lock.json": "6cb01b419c9f77f1784cf1e436a7e74c7950063b4740e437c275a011e71dfa59", "package.json": "7ade5fcc458b7cba19e9eb8573b0131e7bc49189bd8ca689ebbada3ee3b03e38", "supabase/config.toml": "8b0b8a0f641b90d9a694906688f18c654789e88203e605f149f1bf7328ca51f8", "supabase/functions/blogged-receiver/deno.json": "4f236fe334bddb5ed6fa35f76819ce5bb60e9d5957b350ddf396259893c9d570", "supabase/functions/blogged-receiver/deno.lock": "a4745b3a0fa723b8d863b26356d286a4056b01c41ba2f7e82e2e8561a35c7a82", "supabase/functions/blogged-receiver/index.ts": "0cd611f94b779d6554143aa9f48e3fa7467bb37a11ec4bebbfe869cd7df31bdc", "supabase/functions/blogged-receiver/lib/receiver.d.mts": "f0d524f2b1f972819f33bcf95b6a4bbd0da537ed82c97ea2f351c72972d92fff", "supabase/functions/blogged-receiver/lib/receiver.mjs": "501eca762bf8084292715e3f48761d851073029003d9c475abf2557691115daf", "supabase/functions/blogged-receiver/vendor/protocol.mjs": "e68830cc3fd5362e5f7e029ac48b681bc92df680437ebbbafd7b31c1165e3363", "supabase/migrations/202609120001_blogged_receiver.sql": "0973d038097ed6c3b78b3af648f3e5b68ba25b4027398572efa6917be2fd1a65", "tests/receiver.test.mjs": "b3e180eb76946e9c0fdc1a12640847a53c31ad8da24f97164724ea907d831766", "vendor/protocol.mjs": "e68830cc3fd5362e5f7e029ac48b681bc92df680437ebbbafd7b31c1165e3363" } } ===== FILE: package.json ===== { "name": "@blogged/lovable-receiver", "version": "1.0.0", "private": true, "type": "module", "scripts": { "test": "node --test tests/*.test.mjs" }, "dependencies": { "parse5": "8.0.1" }, "devDependencies": { "@electric-sql/pglite": "0.5.8", "postgres": "3.4.7" } } ===== FILE: supabase/config.toml ===== [functions.blogged-receiver] verify_jwt = false import_map = "./functions/blogged-receiver/deno.json" ===== FILE: supabase/functions/blogged-receiver/deno.json ===== {"imports":{"parse5":"npm:parse5@8.0.1"}} ===== FILE: supabase/functions/blogged-receiver/index.ts ===== // Deploy the release's lib/ and vendor/ directories alongside this entry point. import { configFrom, createHandler } from "./lib/receiver.mjs"; declare const EdgeRuntime: { waitUntil(promise: Promise): void }; const handler = createHandler(configFrom(Deno.env), { waitUntil: (promise: Promise) => EdgeRuntime.waitUntil(promise), }); Deno.serve(handler); ===== FILE: supabase/functions/blogged-receiver/lib/receiver.d.mts ===== export interface ReceiverConfig { secret: string; projectId: string; publicBase: string; endpoint: string; supabaseUrl: string; serviceKey: string; apiOrigin: string; } export type ReceiverDatabase = ( mode: string, args?: Record, options?: { timeoutMs?: number }, ) => Promise; export function configFrom( env: Record | { get(name: string): string | undefined }, ): ReceiverConfig; export function database(config: ReceiverConfig, fetcher?: typeof fetch): ReceiverDatabase; export function processDelivery( db: ReceiverDatabase, id: string, config: ReceiverConfig, options?: { fetcher?: typeof fetch; budgetMs?: number }, ): Promise; export function createHandler( config: ReceiverConfig, options?: { db?: ReceiverDatabase; fetcher?: typeof fetch; waitUntil?: (promise: Promise) => void; processBudgetMs?: number; }, ): (request: Request) => Promise; ===== FILE: supabase/functions/blogged-receiver/lib/receiver.mjs ===== import { Buffer } from "node:buffer"; import { randomUUID } from "node:crypto"; import { authenticate, fail, hashBytes, isId, readBounded, renderArticleHtml, semanticHash, signature, validateDelivery, } from "../vendor/protocol.mjs"; export function configFrom(env) { const value = (key) => (typeof env.get === "function" ? env.get(key) : env[key]); const secret = value("BLOGGED_SIGNING_SECRET"); const projectId = value("BLOGGED_PROJECT_ID"); const publicBase = value("BLOGGED_PUBLIC_BLOG_URL") || ""; const endpoint = value("BLOGGED_RECEIVER_URL") || ""; const supabaseUrl = value("SUPABASE_URL") || ""; const serviceKey = value("SUPABASE_SERVICE_ROLE_KEY"); for (const raw of [publicBase, endpoint, supabaseUrl]) { let u; try { u = new URL(raw); } catch { fail("Complete receiver server configuration.", 503); } if ( u.protocol !== "https:" || u.username || u.password || u.search || u.hash || u.port || u.hostname === "localhost" || u.hostname.endsWith(".local") || /^[\d.]+$/.test(u.hostname) || u.hostname.includes(":") ) fail("Use public HTTPS receiver configuration.", 503); } if ( !secret || secret.length < 32 || !/^[A-Za-z0-9_-]{1,128}$/.test(projectId || "") || !serviceKey || !/^\/[a-z0-9][a-z0-9/-]*$/.test(new URL(publicBase).pathname.replace(/\/$/, "")) ) fail("Complete receiver server configuration.", 503); return { secret, projectId, publicBase: publicBase.replace(/\/$/, ""), endpoint: endpoint.replace(/\/$/, ""), supabaseUrl: supabaseUrl.replace(/\/$/, ""), serviceKey, apiOrigin: "https://app.blogged.dev", }; } export function database(config, fetcher = fetch) { return async (mode, args = {}, { timeoutMs = 15000 } = {}) => { const response = await fetcher(`${config.supabaseUrl}/rest/v1/rpc/blogged_receiver_dispatch`, { method: "POST", redirect: "manual", signal: AbortSignal.timeout(Math.max(1, Math.min(15000, timeoutMs))), headers: { "Content-Type": "application/json", apikey: config.serviceKey, // Modern opaque keys authenticate through apikey, not JWT bearer auth. ...(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(config.serviceKey) ? { Authorization: `Bearer ${config.serviceKey}` } : {}), }, body: JSON.stringify({ mode, args: { ...args, projectId: config.projectId, publicBase: config.publicBase }, }), }); if (!response.ok) fail( response.status === 409 ? "Review the receiver binding or remote article revision." : "Receiver database unavailable.", response.status === 409 ? 409 : 503, ); return JSON.parse((await readBounded(response.body, 8 * 1024 * 1024)).toString("utf8")); }; } const accepted = (body) => ({ version: 1, publicationId: body.publicationId, revision: body.revision, state: "accepted", }); const json = (body, status = 200) => Response.json(body, { status, headers: { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" }, }); function imageType(bytes) { if (bytes.length < 12 || bytes.length > 5 * 1024 * 1024) return null; if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return "image/png"; if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return "image/jpeg"; if (bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") return "image/webp"; if (["GIF87a", "GIF89a"].includes(bytes.toString("ascii", 0, 6))) return "image/gif"; return null; } export async function processDelivery(db, id, config, { fetcher = fetch, budgetMs = 45000 } = {}) { const token = randomUUID(), deadline = Date.now() + Math.min(45000, budgetMs); const originalDb = db; const remaining = () => Math.max(1, Math.min(5000, deadline - Date.now())); db = (mode, args) => originalDb(mode, args, { timeoutMs: remaining() }); const claim = await db("claim", { deliveryId: id, token }); if (!claim) return; let receipt = claim.receipt; if (!receipt) { const body = validateDelivery(claim.body, config); try { for (const asset of body.article.assets) { if (Date.now() + 5000 > deadline) return; if (await db("asset_exists", { id: asset.id })) continue; const response = await fetcher(asset.url, { redirect: "manual", signal: AbortSignal.timeout(remaining()), cache: "no-store", }); if (!response.ok) fail("Image download needs another attempt.", 503); const bytes = await readBounded(response.body, 5 * 1024 * 1024); if (hashBytes(bytes) !== asset.id || imageType(bytes) !== asset.contentType) fail("Image does not match its signed manifest.", 400); if ( !(await db("asset_store", { deliveryId: id, token, id: asset.id, contentType: asset.contentType, base64: bytes.toString("base64"), })) ) return; } const media = new Map( body.article.assets.map((a) => [a.url, `${config.endpoint}?asset=${a.id}`]), ); const article = { ...body.article, html: renderArticleHtml(body.article.html, media), coverImageUrl: media.get(body.article.coverImageUrl) || null, assets: body.article.assets.map((a) => ({ id: a.id, url: media.get(a.url), alt: a.alt || "", })), }; if (Date.now() + 1000 > deadline) return; receipt = await db("finish", { deliveryId: id, token, article }); } finally { await db("release", { deliveryId: id, token }); } } if (receipt && Date.now() < deadline) { const raw = JSON.stringify(receipt), timestamp = String(Math.floor(Date.now() / 1000)); const response = await fetcher(`${config.apiOrigin}/api/integrations/receipts`, { method: "POST", redirect: "manual", signal: AbortSignal.timeout(remaining()), headers: { "Content-Type": "application/json", "X-Blogged-Timestamp": timestamp, "X-Blogged-Signature": signature(config.secret, timestamp, raw), }, body: raw, }); if (!response.ok && response.status !== 409) fail("Receipt will be replayed on redelivery.", 503); } } /** HMAC writes and explicit public read actions share an endpoint; no arbitrary RPC forwarding. */ export function createHandler( config, { db = database(config), fetcher = fetch, waitUntil, processBudgetMs = 45000 } = {}, ) { if (!Number.isSafeInteger(processBudgetMs) || processBudgetMs < 5000 || processBudgetMs > 45000) fail("Invalid receiver processing budget.", 503); return async (request) => { try { const url = new URL(request.url); if (request.method === "GET" || request.method === "HEAD") { let response; if (url.searchParams.has("asset")) { const id = url.searchParams.get("asset"); if (!isId(id)) return json({ error: "Not found" }, 404); const asset = await db("public_asset", { id }); if (!asset) return json({ error: "Not found" }, 404); response = new Response(Buffer.from(asset.base64, "base64"), { headers: { "Content-Type": asset.contentType, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", }, }); } else if (url.searchParams.has("slug")) { const slug = url.searchParams.get("slug"); if (!/^[a-z0-9-]{1,160}$/.test(slug || "")) return json({ error: "Not found" }, 404); const row = await db("public_post", { slug }); response = row ? json(row) : json({ error: "Not found" }, 404); } else if (url.searchParams.get("count") === "1") { response = json(await db("public_count")); } else if (url.searchParams.get("list") === "1") { const offset = Number(url.searchParams.get("offset") || 0); if (!Number.isSafeInteger(offset) || offset < 0 || offset > 100000) return json({ error: "Invalid page" }, 400); response = json(await db("public_list", { offset })); } else return json({ error: "Not found" }, 404); return request.method === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response; } if (request.method !== "POST") return json({ error: "Method not allowed" }, 405); if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) return json({ error: "Expected JSON" }, 415); const raw = (await readBounded(request.body, 1000000)).toString("utf8"); const body = authenticate( raw, request.headers.get("x-blogged-timestamp"), request.headers.get("x-blogged-signature"), config.secret, ); if (body.event === "connection.test") { if (body.siteId !== config.projectId) fail("Receiver belongs to another Blogged Project.", 403); if (typeof waitUntil !== "function") fail("Durable background invocation support is required.", 503); const page = await fetcher(config.publicBase, { redirect: "manual", signal: AbortSignal.timeout(15000), cache: "no-store", }); const html = (await readBounded(page.body, 1000000)).toString("utf8"); if ( !page.ok || !page.headers.get("content-type")?.includes("text/html") || !html.includes('data-blogged-receiver="1"') ) fail( "Publish the server-rendered Blogged blog routes before verifying. Vite-only shells are not supported.", 422, ); await db("probe"); return json({ version: 1, receiver: "blogged-lovable", receiverVersion: "1.0.0", canPublish: true, canUpdate: true, canWithdraw: true, copiesImages: true, canNoindex: true, canCompareRevision: true, idempotent: true, orderedRevisions: true, }); } if (body.event === "article.inspect") { if (!isId(body.publicationId)) fail("Invalid publication ID.", 400); const row = await db("inspect", { id: body.publicationId }); return json({ version: 1, publicationId: body.publicationId, found: !!row, ...(row ? { record: { id: row.id, revision: row.remote_revision || "", url: row.state === "published" ? `${config.publicBase}/${row.slug}` : null, state: row.state, }, article: row.article || { title: "", html: "", summary: "", seoTitle: "", metaDescription: "", }, } : {}), }); } validateDelivery(body, config); if (typeof waitUntil !== "function") fail("Background invocation support is required.", 503); const receipt = await db("receive", { body, semantic: semanticHash(body) }); if (!receipt) waitUntil( processDelivery(db, body.deliveryId, config, { fetcher, budgetMs: processBudgetMs, }).catch(() => { console.error("Blogged receiver delivery needs redelivery or operator review."); }), ); return json(receipt || accepted(body), receipt ? 200 : 202); } catch (error) { return json( { error: error.status && error.status < 500 ? error.message : "Receiver temporarily unavailable.", }, error.status || 503, ); } }; } ===== FILE: supabase/functions/blogged-receiver/vendor/protocol.mjs ===== // Frozen protocol snapshot from Blogged receiver at 74f838d0; kept separate from existing installations. import { Buffer } from "node:buffer"; import { createHash, createHmac, timingSafeEqual } from "node:crypto"; import { parseFragment, serialize } from "parse5"; export const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex"); export const hashBytes = (value) => createHash("sha256").update(value).digest("hex"); export const fail = (message, status = 409) => { throw Object.assign(new Error(message), { status }); }; export const isId = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value); export function signature(secret, timestamp, raw) { return "sha256=" + createHmac("sha256", secret).update(`${timestamp}.${raw}`).digest("hex"); } export function authenticate(raw, timestamp, signed, secret, now = Date.now()) { if ( Buffer.byteLength(raw) > 1_000_000 || !secret || secret.length < 32 || !/^\d{10}$/.test(timestamp || "") || Math.abs(now / 1000 - Number(timestamp)) > 300 || !/^sha256=[a-f0-9]{64}$/.test(signed || "") ) fail("Invalid signed request.", 401); if (!timingSafeEqual(Buffer.from(signature(secret, timestamp, raw)), Buffer.from(signed))) fail("Invalid signed request.", 401); let body; try { body = JSON.parse(raw); } catch { fail("Invalid JSON.", 400); } if (!body || body.version !== 1) fail("Unsupported receiver contract.", 400); return body; } export function receiverConfig(env = process.env) { const publicUrl = new URL(env.BLOGGED_PUBLIC_BLOG_URL || "https://invalid.example/blog"); const apiUrl = new URL(env.BLOGGED_API_ORIGIN || "https://app.blogged.dev"); if ( !env.BLOGGED_PUBLIC_BLOG_URL || publicUrl.protocol !== "https:" || publicUrl.username || publicUrl.password || publicUrl.search || publicUrl.hash || publicUrl.pathname.replace(/\/$/, "") !== "/blog" || apiUrl.protocol !== "https:" || apiUrl.origin !== apiUrl.href.replace(/\/$/, "") || !env.BLOGGED_PROJECT_ID || !env.BLOGGED_SIGNING_SECRET || env.BLOGGED_SIGNING_SECRET.length < 32 || env.BLOGGED_SIGNING_SECRET.startsWith("replace-with-") ) fail("Complete the receiver server environment.", 503); return { publicBase: publicUrl.href.replace(/\/$/, ""), origin: publicUrl.origin, apiOrigin: apiUrl.origin, projectId: env.BLOGGED_PROJECT_ID, secret: env.BLOGGED_SIGNING_SECRET, }; } const tags = new Set( "p br hr h2 h3 h4 h5 h6 strong em b i u s blockquote ul ol li pre code a img figure figcaption table thead tbody tfoot tr th td div span".split( " ", ), ); const drop = new Set( "script style iframe form input button textarea select option object embed svg math link meta base template".split( " ", ), ); /** Defense in depth: never execute source HTML, even with a valid HMAC. */ export function renderArticleHtml(html, media) { const root = parseFragment(html); const visit = (parent) => { parent.childNodes = (parent.childNodes || []).flatMap((node) => { if (!node.tagName) return node.nodeName === "#text" ? [node] : []; if (drop.has(node.tagName)) return []; visit(node); if (!tags.has(node.tagName)) { for (const child of node.childNodes) child.parentNode = parent; return node.childNodes; } const attributes = Object.fromEntries( (node.attrs || []).map((attr) => [attr.name, attr.value]), ); node.attrs = []; const add = (name, value) => node.attrs.push({ name, value }); if (node.tagName === "a") { try { const url = new URL(attributes.href); if (["https:", "http:"].includes(url.protocol) && !url.username && !url.password) add("href", url.href); } catch {} if (/\b(sponsored|nofollow)\b/.test(attributes.rel || "")) add( "rel", (attributes.rel || "") .split(/\s+/) .filter((value) => ["sponsored", "nofollow"].includes(value)) .join(" "), ); } if (node.tagName === "img") { const url = media.get(attributes.src); if (!url) fail("An image is missing from the durable asset manifest.", 400); add("src", url); add("alt", String(attributes.alt || "").slice(0, 500)); add("loading", "lazy"); } if (["th", "td"].includes(node.tagName)) for (const name of ["colspan", "rowspan"]) if (/^[1-9][0-9]?$/.test(attributes[name] || "")) add(name, attributes[name]); return [node]; }); }; visit(root); return serialize(root); } export function validateDelivery(body, config) { if ( !isId(body.publicationId) || !Number.isSafeInteger(body.revision) || body.revision < 1 || !["publish", "update", "withdraw"].includes(body.operation) || body.event !== `article.${body.operation}` || body.deliveryId !== digest([body.publicationId, body.revision, body.operation]) || !["primary", "summary"].includes(body.role) || (body.expectedRemoteRevision !== null && (typeof body.expectedRemoteRevision !== "string" || body.expectedRemoteRevision.length > 512)) ) fail("Invalid delivery identity.", 400); if (body.operation === "withdraw") { if (body.article !== null) fail("Invalid withdrawal.", 400); return body; } const a = body.article; if ( !a || typeof a.title !== "string" || !a.title.trim() || a.title.length > 500 || typeof a.slug !== "string" || !/^[a-z0-9][a-z0-9-]{0,199}$/.test(a.slug) || typeof a.html !== "string" || a.html.length > 900_000 || typeof a.noindex !== "boolean" || !Array.isArray(a.assets) || a.assets.length > 20 || ["seoTitle", "metaDescription", "summary"].some( (key) => typeof a[key] !== "string" || a[key].length > 2000, ) ) fail("Invalid article content.", 400); const seen = new Set(); for (const asset of a.assets) { let url; try { url = new URL(asset.url); } catch { fail("Invalid asset URL.", 400); } if ( !isId(asset.id) || seen.has(asset.id) || url.origin !== config.apiOrigin || !/^\/api\/integrations\/media\/[a-f0-9]{64}$/.test(url.pathname) || url.username || url.password || url.hash || url.searchParams.get("delivery") !== body.deliveryId || !["image/png", "image/jpeg", "image/webp", "image/gif"].includes(asset.contentType) ) fail("Invalid scoped asset.", 400); seen.add(asset.id); } if (a.coverImageUrl !== null && !a.assets.some((asset) => asset.url === a.coverImageUrl)) fail("Cover image is missing from the manifest.", 400); if (body.role === "summary" && (!a.noindex || !a.primaryUrl || !/^https:\/\//.test(a.primaryUrl))) fail("Summary needs noindex and a full-article URL.", 400); return body; } /** Ignore expiring grants, but bind the exact article and every immutable asset hash. */ export function semanticHash(body) { if (!body.article) return digest(body); const mapping = new Map( body.article.assets.map((asset) => [asset.url, `https://assets.invalid/${asset.id}`]), ); const article = { ...body.article, html: renderArticleHtml(body.article.html, mapping), coverImageUrl: mapping.get(body.article.coverImageUrl) || null, assets: body.article.assets .map(({ url, ...asset }) => asset) .sort((a, b) => a.id.localeCompare(b.id)), }; return digest({ ...body, article }); } export async function readBounded(stream, max) { if (!stream) return Buffer.alloc(0); const reader = stream.getReader(), parts = []; let size = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; size += value.length; if (size > max) { await reader.cancel(); fail("Payload exceeds the size limit.", 413); } parts.push(value); } } finally { reader.releaseLock(); } return Buffer.concat(parts, size); } ===== FILE: supabase/migrations/202609120001_blogged_receiver.sql ===== -- Additive receiver namespace. No existing application tables or policies are changed. BEGIN; CREATE SCHEMA IF NOT EXISTS blogged_receiver; REVOKE ALL ON SCHEMA blogged_receiver FROM PUBLIC, anon, authenticated; CREATE TABLE IF NOT EXISTS blogged_receiver.settings ( singleton boolean PRIMARY KEY DEFAULT true CHECK(singleton), version integer NOT NULL CHECK(version=1), project_id text NOT NULL, public_base text NOT NULL ); CREATE TABLE IF NOT EXISTS blogged_receiver.publications ( id text PRIMARY KEY CHECK(id ~ '^[a-f0-9]{64}$'), slug text NOT NULL UNIQUE, desired_revision bigint NOT NULL DEFAULT 0, desired_delivery text, remote_revision text, state text NOT NULL DEFAULT 'accepted' CHECK(state IN ('accepted','published','withdrawn')), article jsonb, published_at timestamptz, updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS blogged_receiver.deliveries ( id text PRIMARY KEY CHECK(id ~ '^[a-f0-9]{64}$'), publication_id text NOT NULL REFERENCES blogged_receiver.publications, revision bigint NOT NULL CHECK(revision>0), semantic_hash text NOT NULL, payload jsonb NOT NULL, receipt jsonb, lease_token text, lease_until timestamptz, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(publication_id, revision) ); CREATE TABLE IF NOT EXISTS blogged_receiver.assets ( id text PRIMARY KEY CHECK(id ~ '^[a-f0-9]{64}$'), content_type text NOT NULL, bytes bytea NOT NULL CHECK(octet_length(bytes) BETWEEN 12 AND 5242880), created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS blogged_receiver.article_assets ( publication_id text NOT NULL REFERENCES blogged_receiver.publications, asset_id text NOT NULL REFERENCES blogged_receiver.assets, PRIMARY KEY(publication_id,asset_id) ); ALTER TABLE blogged_receiver.settings ENABLE ROW LEVEL SECURITY; ALTER TABLE blogged_receiver.publications ENABLE ROW LEVEL SECURITY; ALTER TABLE blogged_receiver.deliveries ENABLE ROW LEVEL SECURITY; ALTER TABLE blogged_receiver.assets ENABLE ROW LEVEL SECURITY; ALTER TABLE blogged_receiver.article_assets ENABLE ROW LEVEL SECURITY; CREATE INDEX IF NOT EXISTS blogged_receiver_live ON blogged_receiver.publications(state,published_at DESC,id); -- The only Data API entry point. Only the backend service role may execute it. -- Mutations always lock publication before delivery, including withdrawals. CREATE OR REPLACE FUNCTION public.blogged_receiver_dispatch(mode text, args jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, blogged_receiver AS $$ DECLARE p blogged_receiver.publications; d blogged_receiver.deliveries; s blogged_receiver.settings; b jsonb; result jsonb; rev text; aid text; probe text; BEGIN IF mode='probe' THEN INSERT INTO blogged_receiver.settings(singleton,version,project_id,public_base) VALUES(true,1,args->>'projectId',args->>'publicBase') ON CONFLICT DO NOTHING; SELECT * INTO s FROM blogged_receiver.settings WHERE singleton FOR UPDATE; IF s.version<>1 OR s.project_id IS DISTINCT FROM args->>'projectId' OR s.public_base IS DISTINCT FROM args->>'publicBase' THEN RAISE EXCEPTION USING ERRCODE='PT409', MESSAGE='Receiver is bound to another Project or public URL. Use a separate installation.'; END IF; -- Exercise real write permissions and constraints in a subtransaction, then roll it back. probe:=replace(gen_random_uuid()::text,'-','') || replace(gen_random_uuid()::text,'-',''); BEGIN INSERT INTO blogged_receiver.publications(id,slug) VALUES(probe,'probe-'||probe); INSERT INTO blogged_receiver.deliveries(id,publication_id,revision,semantic_hash,payload) VALUES(probe,probe,1,probe,'{}'); INSERT INTO blogged_receiver.assets(id,content_type,bytes) VALUES(probe,'image/png',decode(repeat('00',12),'hex')); INSERT INTO blogged_receiver.article_assets VALUES(probe,probe); RAISE EXCEPTION USING ERRCODE='P9001', MESSAGE='rollback probe'; EXCEPTION WHEN SQLSTATE 'P9001' THEN NULL; END; RETURN jsonb_build_object('version',1); END IF; SELECT * INTO s FROM blogged_receiver.settings WHERE singleton; IF NOT FOUND AND mode IN ('public_list','public_post','public_asset','public_count') THEN RETURN CASE WHEN mode='public_list' THEN '[]'::jsonb WHEN mode='public_count' THEN '0'::jsonb ELSE NULL END; END IF; IF s.project_id IS DISTINCT FROM args->>'projectId' OR s.public_base IS DISTINCT FROM args->>'publicBase' THEN RAISE EXCEPTION USING ERRCODE='PT409', MESSAGE='Verify the receiver connection and its Project binding first.'; END IF; IF mode='inspect' THEN SELECT * INTO p FROM blogged_receiver.publications WHERE id=args->>'id'; IF NOT FOUND THEN RETURN NULL; END IF; RETURN to_jsonb(p)-'desired_delivery'-'desired_revision'; ELSIF mode='public_post' THEN SELECT * INTO p FROM blogged_receiver.publications WHERE slug=args->>'slug' AND state='published'; IF NOT FOUND THEN RETURN NULL; END IF; RETURN to_jsonb(p)-'desired_delivery'-'desired_revision'-'remote_revision'; ELSIF mode='public_count' THEN RETURN to_jsonb((SELECT count(*) FROM blogged_receiver.publications WHERE state='published')); ELSIF mode='public_list' THEN SELECT coalesce(jsonb_agg(x),'[]'::jsonb) INTO result FROM ( SELECT id,slug,article->>'title' AS title,article->>'summary' AS summary, article->>'coverImageUrl' AS cover_image_url,article->>'noindex' AS noindex,published_at,updated_at FROM blogged_receiver.publications WHERE state='published' ORDER BY published_at DESC,id LIMIT 100 OFFSET least(100000,greatest(0,(args->>'offset')::integer)) ) x; RETURN result; ELSIF mode='public_asset' THEN SELECT jsonb_build_object('contentType',a.content_type,'base64',encode(a.bytes,'base64')) INTO result FROM blogged_receiver.assets a WHERE a.id=args->>'id' AND EXISTS ( SELECT 1 FROM blogged_receiver.article_assets x JOIN blogged_receiver.publications pub ON pub.id=x.publication_id WHERE x.asset_id=a.id AND pub.state='published'); RETURN result; ELSIF mode='asset_exists' THEN RETURN to_jsonb(EXISTS(SELECT 1 FROM blogged_receiver.assets WHERE id=args->>'id')); END IF; IF mode='receive' THEN b:=args->'body'; INSERT INTO blogged_receiver.publications(id,slug) VALUES(b->>'publicationId',coalesce(left(b->'article'->>'slug',120),'article')||'-'||left(b->>'publicationId',12)) ON CONFLICT(id) DO NOTHING; SELECT * INTO p FROM blogged_receiver.publications WHERE id=b->>'publicationId' FOR UPDATE; SELECT * INTO d FROM blogged_receiver.deliveries WHERE id=b->>'deliveryId' FOR UPDATE; IF FOUND THEN IF d.semantic_hash IS DISTINCT FROM args->>'semantic' OR d.publication_id<>p.id OR p.desired_delivery<>d.id THEN RAISE EXCEPTION USING ERRCODE='PT409', MESSAGE='Conflicting or superseded delivery.'; END IF; UPDATE blogged_receiver.deliveries SET payload=b WHERE id=d.id; RETURN d.receipt; END IF; IF p.desired_revision >= (b->>'revision')::bigint OR p.remote_revision IS DISTINCT FROM b->>'expectedRemoteRevision' OR (b->>'operation'='update' AND p.remote_revision IS NULL) THEN RAISE EXCEPTION USING ERRCODE='PT409', MESSAGE='Review the current remote revision before publishing.'; END IF; INSERT INTO blogged_receiver.deliveries(id,publication_id,revision,semantic_hash,payload) VALUES(b->>'deliveryId',p.id,(b->>'revision')::bigint,args->>'semantic',b); UPDATE blogged_receiver.publications SET desired_revision=(b->>'revision')::bigint,desired_delivery=b->>'deliveryId',updated_at=now() WHERE id=p.id; IF b->>'operation'='withdraw' THEN rev:=gen_random_uuid()::text; UPDATE blogged_receiver.publications SET state='withdrawn',remote_revision=rev WHERE id=p.id; result:=jsonb_build_object('version',1,'deliveryId',b->>'deliveryId','publicationId',p.id,'revision',b->'revision', 'operation',b->>'operation','state','withdrawn','remoteId',p.id,'remoteRevision',rev,'url',NULL,'assetsCopied',true); UPDATE blogged_receiver.deliveries SET receipt=result WHERE id=b->>'deliveryId'; RETURN result; END IF; RETURN NULL; END IF; SELECT * INTO d FROM blogged_receiver.deliveries WHERE id=args->>'deliveryId'; IF NOT FOUND THEN RETURN NULL; END IF; SELECT * INTO p FROM blogged_receiver.publications WHERE id=d.publication_id FOR UPDATE; SELECT * INTO d FROM blogged_receiver.deliveries WHERE id=args->>'deliveryId' FOR UPDATE; IF p.desired_delivery<>d.id THEN RETURN NULL; END IF; IF mode='claim' THEN IF d.receipt IS NOT NULL THEN RETURN jsonb_build_object('receipt',d.receipt); END IF; IF d.lease_until>now() THEN RETURN NULL; END IF; IF p.remote_revision IS DISTINCT FROM d.payload->>'expectedRemoteRevision' THEN RAISE EXCEPTION USING ERRCODE='PT409', MESSAGE='Remote revision changed after acceptance.'; END IF; UPDATE blogged_receiver.deliveries SET lease_token=args->>'token',lease_until=now()+interval '90 seconds' WHERE id=d.id; RETURN jsonb_build_object('body',d.payload); ELSIF mode='release' THEN UPDATE blogged_receiver.deliveries SET lease_token=NULL,lease_until=NULL WHERE id=d.id AND lease_token=args->>'token'; RETURN NULL; END IF; IF d.lease_token IS DISTINCT FROM args->>'token' OR d.lease_until IS NULL OR d.lease_until<=now() OR d.receipt IS NOT NULL THEN RETURN NULL; END IF; IF mode='asset_store' THEN aid:=args->>'id'; IF NOT EXISTS(SELECT 1 FROM jsonb_array_elements(d.payload->'article'->'assets') a WHERE a->>'id'=aid AND a->>'contentType'=args->>'contentType') THEN RAISE EXCEPTION USING ERRCODE='PT400', MESSAGE='Asset is outside this delivery.'; END IF; INSERT INTO blogged_receiver.assets(id,content_type,bytes) VALUES(aid,args->>'contentType',decode(args->>'base64','base64')) ON CONFLICT DO NOTHING; RETURN 'true'::jsonb; ELSIF mode='finish' THEN IF p.remote_revision IS DISTINCT FROM d.payload->>'expectedRemoteRevision' THEN RAISE EXCEPTION USING ERRCODE='PT409', MESSAGE='Remote revision changed during delivery.'; END IF; FOR aid IN SELECT a->>'id' FROM jsonb_array_elements(d.payload->'article'->'assets') a LOOP IF NOT EXISTS(SELECT 1 FROM blogged_receiver.assets WHERE id=aid) THEN RAISE EXCEPTION USING ERRCODE='PT409', MESSAGE='Image copying is incomplete.'; END IF; END LOOP; rev:=gen_random_uuid()::text; UPDATE blogged_receiver.publications SET state='published',remote_revision=rev,article=args->'article',published_at=coalesce(published_at,now()),updated_at=now() WHERE id=p.id; DELETE FROM blogged_receiver.article_assets WHERE publication_id=p.id; INSERT INTO blogged_receiver.article_assets SELECT p.id,a->>'id' FROM jsonb_array_elements(d.payload->'article'->'assets') a; result:=jsonb_build_object('version',1,'deliveryId',d.id,'publicationId',p.id,'revision',d.revision,'operation',d.payload->>'operation', 'state','published','remoteId',p.id,'remoteRevision',rev,'url',s.public_base||'/'||p.slug,'assetsCopied',true); UPDATE blogged_receiver.deliveries SET receipt=result,lease_token=NULL,lease_until=NULL WHERE id=d.id; RETURN result; END IF; RAISE EXCEPTION USING ERRCODE='PT400', MESSAGE='Unsupported receiver operation.'; END; $$; REVOKE ALL ON FUNCTION public.blogged_receiver_dispatch(text,jsonb) FROM PUBLIC, anon, authenticated; GRANT EXECUTE ON FUNCTION public.blogged_receiver_dispatch(text,jsonb) TO service_role; COMMIT; ===== FILE: vendor/protocol.mjs ===== // Frozen protocol snapshot from Blogged receiver at 74f838d0; kept separate from existing installations. import { Buffer } from "node:buffer"; import { createHash, createHmac, timingSafeEqual } from "node:crypto"; import { parseFragment, serialize } from "parse5"; export const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex"); export const hashBytes = (value) => createHash("sha256").update(value).digest("hex"); export const fail = (message, status = 409) => { throw Object.assign(new Error(message), { status }); }; export const isId = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value); export function signature(secret, timestamp, raw) { return "sha256=" + createHmac("sha256", secret).update(`${timestamp}.${raw}`).digest("hex"); } export function authenticate(raw, timestamp, signed, secret, now = Date.now()) { if ( Buffer.byteLength(raw) > 1_000_000 || !secret || secret.length < 32 || !/^\d{10}$/.test(timestamp || "") || Math.abs(now / 1000 - Number(timestamp)) > 300 || !/^sha256=[a-f0-9]{64}$/.test(signed || "") ) fail("Invalid signed request.", 401); if (!timingSafeEqual(Buffer.from(signature(secret, timestamp, raw)), Buffer.from(signed))) fail("Invalid signed request.", 401); let body; try { body = JSON.parse(raw); } catch { fail("Invalid JSON.", 400); } if (!body || body.version !== 1) fail("Unsupported receiver contract.", 400); return body; } export function receiverConfig(env = process.env) { const publicUrl = new URL(env.BLOGGED_PUBLIC_BLOG_URL || "https://invalid.example/blog"); const apiUrl = new URL(env.BLOGGED_API_ORIGIN || "https://app.blogged.dev"); if ( !env.BLOGGED_PUBLIC_BLOG_URL || publicUrl.protocol !== "https:" || publicUrl.username || publicUrl.password || publicUrl.search || publicUrl.hash || publicUrl.pathname.replace(/\/$/, "") !== "/blog" || apiUrl.protocol !== "https:" || apiUrl.origin !== apiUrl.href.replace(/\/$/, "") || !env.BLOGGED_PROJECT_ID || !env.BLOGGED_SIGNING_SECRET || env.BLOGGED_SIGNING_SECRET.length < 32 || env.BLOGGED_SIGNING_SECRET.startsWith("replace-with-") ) fail("Complete the receiver server environment.", 503); return { publicBase: publicUrl.href.replace(/\/$/, ""), origin: publicUrl.origin, apiOrigin: apiUrl.origin, projectId: env.BLOGGED_PROJECT_ID, secret: env.BLOGGED_SIGNING_SECRET, }; } const tags = new Set( "p br hr h2 h3 h4 h5 h6 strong em b i u s blockquote ul ol li pre code a img figure figcaption table thead tbody tfoot tr th td div span".split( " ", ), ); const drop = new Set( "script style iframe form input button textarea select option object embed svg math link meta base template".split( " ", ), ); /** Defense in depth: never execute source HTML, even with a valid HMAC. */ export function renderArticleHtml(html, media) { const root = parseFragment(html); const visit = (parent) => { parent.childNodes = (parent.childNodes || []).flatMap((node) => { if (!node.tagName) return node.nodeName === "#text" ? [node] : []; if (drop.has(node.tagName)) return []; visit(node); if (!tags.has(node.tagName)) { for (const child of node.childNodes) child.parentNode = parent; return node.childNodes; } const attributes = Object.fromEntries( (node.attrs || []).map((attr) => [attr.name, attr.value]), ); node.attrs = []; const add = (name, value) => node.attrs.push({ name, value }); if (node.tagName === "a") { try { const url = new URL(attributes.href); if (["https:", "http:"].includes(url.protocol) && !url.username && !url.password) add("href", url.href); } catch {} if (/\b(sponsored|nofollow)\b/.test(attributes.rel || "")) add( "rel", (attributes.rel || "") .split(/\s+/) .filter((value) => ["sponsored", "nofollow"].includes(value)) .join(" "), ); } if (node.tagName === "img") { const url = media.get(attributes.src); if (!url) fail("An image is missing from the durable asset manifest.", 400); add("src", url); add("alt", String(attributes.alt || "").slice(0, 500)); add("loading", "lazy"); } if (["th", "td"].includes(node.tagName)) for (const name of ["colspan", "rowspan"]) if (/^[1-9][0-9]?$/.test(attributes[name] || "")) add(name, attributes[name]); return [node]; }); }; visit(root); return serialize(root); } export function validateDelivery(body, config) { if ( !isId(body.publicationId) || !Number.isSafeInteger(body.revision) || body.revision < 1 || !["publish", "update", "withdraw"].includes(body.operation) || body.event !== `article.${body.operation}` || body.deliveryId !== digest([body.publicationId, body.revision, body.operation]) || !["primary", "summary"].includes(body.role) || (body.expectedRemoteRevision !== null && (typeof body.expectedRemoteRevision !== "string" || body.expectedRemoteRevision.length > 512)) ) fail("Invalid delivery identity.", 400); if (body.operation === "withdraw") { if (body.article !== null) fail("Invalid withdrawal.", 400); return body; } const a = body.article; if ( !a || typeof a.title !== "string" || !a.title.trim() || a.title.length > 500 || typeof a.slug !== "string" || !/^[a-z0-9][a-z0-9-]{0,199}$/.test(a.slug) || typeof a.html !== "string" || a.html.length > 900_000 || typeof a.noindex !== "boolean" || !Array.isArray(a.assets) || a.assets.length > 20 || ["seoTitle", "metaDescription", "summary"].some( (key) => typeof a[key] !== "string" || a[key].length > 2000, ) ) fail("Invalid article content.", 400); const seen = new Set(); for (const asset of a.assets) { let url; try { url = new URL(asset.url); } catch { fail("Invalid asset URL.", 400); } if ( !isId(asset.id) || seen.has(asset.id) || url.origin !== config.apiOrigin || !/^\/api\/integrations\/media\/[a-f0-9]{64}$/.test(url.pathname) || url.username || url.password || url.hash || url.searchParams.get("delivery") !== body.deliveryId || !["image/png", "image/jpeg", "image/webp", "image/gif"].includes(asset.contentType) ) fail("Invalid scoped asset.", 400); seen.add(asset.id); } if (a.coverImageUrl !== null && !a.assets.some((asset) => asset.url === a.coverImageUrl)) fail("Cover image is missing from the manifest.", 400); if (body.role === "summary" && (!a.noindex || !a.primaryUrl || !/^https:\/\//.test(a.primaryUrl))) fail("Summary needs noindex and a full-article URL.", 400); return body; } /** Ignore expiring grants, but bind the exact article and every immutable asset hash. */ export function semanticHash(body) { if (!body.article) return digest(body); const mapping = new Map( body.article.assets.map((asset) => [asset.url, `https://assets.invalid/${asset.id}`]), ); const article = { ...body.article, html: renderArticleHtml(body.article.html, mapping), coverImageUrl: mapping.get(body.article.coverImageUrl) || null, assets: body.article.assets .map(({ url, ...asset }) => asset) .sort((a, b) => a.id.localeCompare(b.id)), }; return digest({ ...body, article }); } export async function readBounded(stream, max) { if (!stream) return Buffer.alloc(0); const reader = stream.getReader(), parts = []; let size = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; size += value.length; if (size > max) { await reader.cancel(); fail("Payload exceeds the size limit.", 413); } parts.push(value); } } finally { reader.releaseLock(); } return Buffer.concat(parts, size); }