From ce4570f8b6c5dc171212f21da3ace389ff939812 Mon Sep 17 00:00:00 2001
From: yuriassuncx
Date: Wed, 27 May 2026 11:58:13 -0300
Subject: [PATCH 01/52] feat: add Spire webhook integration for Blog CMS
---
blog/actions/importSpirePost.ts | 196 +++++++++++++++++++++++
blog/manifest.gen.ts | 14 +-
blog/mod.ts | 6 +
blog/types.ts | 9 ++
spire/manifest.gen.ts | 72 +++++----
spire/sections/SpireDashboard.tsx | 64 ++++++++
spire/sections/SpirePendingApprovals.tsx | 189 ++++++++++++++++++++++
7 files changed, 510 insertions(+), 40 deletions(-)
create mode 100644 blog/actions/importSpirePost.ts
create mode 100644 spire/sections/SpireDashboard.tsx
create mode 100644 spire/sections/SpirePendingApprovals.tsx
diff --git a/blog/actions/importSpirePost.ts b/blog/actions/importSpirePost.ts
new file mode 100644
index 000000000..7ef595c77
--- /dev/null
+++ b/blog/actions/importSpirePost.ts
@@ -0,0 +1,196 @@
+import { AppContext } from "../mod.ts";
+import { BlogPost } from "../types.ts";
+import { spirePostToBlogPost } from "../../spire/loaders/BlogPostPage.ts";
+import { join } from "std/path/mod.ts";
+import { SpirePost, Block } from "../../spire/types.ts";
+
+export interface Props {
+ postId: string;
+ postSlug: string;
+ blogSlug: string;
+ event: "post.published" | "post.unpublished" | "post.saved";
+}
+
+/**
+ * @title Import Spire Post Webhook
+ * @description Securely receives a post update webhook from Spire, fetches the full post, compiles it to HTML, and saves it natively in Deco.
+ */
+export default async function importSpirePost(
+ { postSlug, blogSlug, event }: Props,
+ req: Request,
+ _ctx: AppContext,
+): Promise<{ success: boolean; path?: string; message?: string }> {
+ try {
+ // 1. Security Authorization Header Verification
+ const expectedSecret =
+ (typeof _ctx.spireWebhookSecret === "string"
+ ? _ctx.spireWebhookSecret
+ : _ctx.spireWebhookSecret?.get?.()) ||
+ Deno.env.get("SPIRE_WEBHOOK_SECRET");
+ if (expectedSecret) {
+ const authHeader = req.headers.get("Authorization");
+ if (!authHeader || authHeader !== `Bearer ${expectedSecret}`) {
+ return {
+ success: false,
+ message: "Unauthorized: Webhook token verification failed."
+ };
+ }
+ } else {
+ console.warn("[Webhook] Warning: SPIRE_WEBHOOK_SECRET is not configured. Webhook request authorized without verification (development mode).");
+ }
+
+ if (event === "post.unpublished") {
+ // Handle unpublishing/deletion: Remove block file
+ const blocksDir = join(Deno.cwd(), ".deco", "blocks", "collections", "blog", "posts");
+ const filePath = join(blocksDir, `${postSlug}.json`);
+ try {
+ await Deno.remove(filePath);
+ console.info(`[Webhook] Successfully removed unpublished post at: ${filePath}`);
+ return { success: true, message: "Post unpublished successfully" };
+ } catch (err) {
+ if (err instanceof Deno.errors.NotFound) {
+ return { success: true, message: "Post already unpublished or not found" };
+ }
+ throw err;
+ }
+ }
+
+ // 2. Fetch full post content from Spire API
+ const response = await fetch(`https://spire.blog/api/blog/${blogSlug}/posts/${postSlug}`);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch post from Spire: ${response.status} ${response.statusText}`);
+ }
+
+ const { post } = (await response.json()) as { post?: SpirePost };
+ if (!post) {
+ throw new Error("No post content returned from Spire API");
+ }
+
+ // 3. Convert SpirePost schema to BlogPost
+ const baseBlogPost = spirePostToBlogPost(post, {});
+
+ // 4. Compile Spire content blocks into a clean semantic HTML string for Deco Admin WYSIWYG
+ const htmlContent = compileBlocksToHtml(post.version.blocks);
+
+ const blogPost: BlogPost = {
+ ...baseBlogPost,
+ content: htmlContent,
+ spirePostId: post.id,
+ spireWarning: "This post is automatically synchronized by Spire. Any manual edits made in this form will be overwritten during the next sync.",
+ };
+
+ // 5. Build Deco Block Resolvable
+ const resolvable = {
+ __resolveType: "blog/loaders/Blogpost.ts",
+ post: blogPost,
+ };
+
+ // 6. Write natively to filesystem as local JSON block (.deco/blocks/collections/blog/posts/.json)
+ const blocksDir = join(Deno.cwd(), ".deco", "blocks", "collections", "blog", "posts");
+ await Deno.mkdir(blocksDir, { recursive: true });
+ const filePath = join(blocksDir, `${postSlug}.json`);
+ await Deno.writeTextFile(filePath, JSON.stringify(resolvable, null, 2));
+
+ console.info(`[Webhook] Successfully imported post and saved to ${filePath}`);
+
+ return {
+ success: true,
+ path: filePath,
+ message: "Post imported and compiled successfully"
+ };
+ } catch (error) {
+ console.error("[Webhook] Error importing Spire post:", error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : "Internal Server Error"
+ };
+ }
+}
+export interface SpireBlockContent {
+ html?: string;
+ text?: string;
+ level?: string | number;
+ style?: string;
+ items?: string | string[];
+ quote?: string;
+ attribution?: string;
+ variant?: string;
+ title?: string;
+ body?: string;
+ steps?: string | Array<{ title?: string; description?: string }>;
+ url?: string;
+ alt?: string;
+ caption?: string;
+ language?: string;
+ code?: string;
+ href?: string;
+}
+
+/**
+ * Compiles Spire's modular block arrays to semantic standard HTML
+ */
+function compileBlocksToHtml(blocks?: Block[]): string {
+ if (!blocks || !Array.isArray(blocks)) return "";
+
+ const sorted = [...blocks].sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
+
+ return sorted.map((block) => {
+ const content = (block.content || {}) as SpireBlockContent;
+ switch (block.type) {
+ case "paragraph":
+ return content.html ? content.html : `${content.text || ""}
`;
+ case "heading": {
+ const level = content.level || "2";
+ return `${content.text || ""} `;
+ }
+ case "list": {
+ const style = content.style === "ordered" ? "ol" : "ul";
+ const items = Array.isArray(content.items)
+ ? content.items
+ : typeof content.items === "string"
+ ? JSON.parse(content.items) as string[]
+ : [];
+ const listItems = items.map((item: string) => `${item} `).join("");
+ return `<${style}>${listItems}${style}>`;
+ }
+ case "divider":
+ return " ";
+ case "quote":
+ return `${content.quote || content.text || ""}
${content.attribution ? `${content.attribution} ` : ""} `;
+ case "callout": {
+ const variant = content.variant || "info";
+ return `${content.title || ""} ${content.body || ""}
`;
+ }
+ case "checklist": {
+ const checkItems = Array.isArray(content.items)
+ ? content.items
+ : typeof content.items === "string"
+ ? JSON.parse(content.items) as string[]
+ : [];
+ const checkList = checkItems.map((item: string) => ` ${item} `).join("");
+ return `${content.title ? `${content.title} ` : ""}${checkList} `;
+ }
+ case "steps": {
+ const stepItems = Array.isArray(content.steps)
+ ? content.steps
+ : typeof content.steps === "string"
+ ? JSON.parse(content.steps) as Array<{ title?: string; description?: string }>
+ : [];
+ const stepList = stepItems.map((step: { title?: string; description?: string }, index: number) => `${index + 1}. ${step.title || ""} ${step.description ? `${step.description}
` : ""} `).join("");
+ return `${content.title ? `${content.title} ` : ""}${stepList} `;
+ }
+ case "image":
+ return ` ${content.caption ? `${content.caption} ` : ""} `;
+ case "video":
+ return ` ${content.caption ? `${content.caption} ` : ""} `;
+ case "code":
+ return `${content.code || ""} `;
+ case "cta":
+ return ``;
+ default:
+ if (content.html) return content.html;
+ if (content.text) return `${content.text}
`;
+ return "";
+ }
+ }).join("\n");
+}
diff --git a/blog/manifest.gen.ts b/blog/manifest.gen.ts
index ce8f6fe2b..b34853a6c 100644
--- a/blog/manifest.gen.ts
+++ b/blog/manifest.gen.ts
@@ -2,9 +2,10 @@
// This file SHOULD be checked into source version control.
// This file is automatically updated during development when running `dev.ts`.
-import * as $$$$$$$$$0 from "./actions/submitRating.ts";
-import * as $$$$$$$$$1 from "./actions/submitReview.ts";
-import * as $$$$$$$$$2 from "./actions/submitView.ts";
+import * as $$$$$$$$$0 from "./actions/importSpirePost.ts";
+import * as $$$$$$$$$1 from "./actions/submitRating.ts";
+import * as $$$$$$$$$2 from "./actions/submitReview.ts";
+import * as $$$$$$$$$3 from "./actions/submitView.ts";
import * as $$$0 from "./loaders/Author.ts";
import * as $$$4 from "./loaders/Blogpost.ts";
import * as $$$1 from "./loaders/BlogPostItem.ts";
@@ -54,9 +55,10 @@ const manifest = {
"blog/sections/Template.tsx": $$$$$$2,
},
"actions": {
- "blog/actions/submitRating.ts": $$$$$$$$$0,
- "blog/actions/submitReview.ts": $$$$$$$$$1,
- "blog/actions/submitView.ts": $$$$$$$$$2,
+ "blog/actions/importSpirePost.ts": $$$$$$$$$0,
+ "blog/actions/submitRating.ts": $$$$$$$$$1,
+ "blog/actions/submitReview.ts": $$$$$$$$$2,
+ "blog/actions/submitView.ts": $$$$$$$$$3,
},
"name": "blog",
"baseUrl": import.meta.url,
diff --git a/blog/mod.ts b/blog/mod.ts
index f4e9baf8e..e1a6641b2 100644
--- a/blog/mod.ts
+++ b/blog/mod.ts
@@ -1,4 +1,5 @@
import manifest, { Manifest } from "./manifest.gen.ts";
+import { Secret } from "../website/loaders/secret.ts";
import { PreviewContainer } from "../utils/preview.tsx";
import { type App, type FnContext } from "@deco/deco";
export type State = {
@@ -7,6 +8,11 @@ export type State = {
* @description The slug of the BlogPostPage to embed. Use :category and :slug.
*/
pageSlug?: string;
+ /**
+ * @title Spire Webhook Secret
+ * @description Secret token to verify incoming webhooks from Spire. Create a Secret here and paste it in your Spire dashboard settings alongside your Deco Webhook URL.
+ */
+ spireWebhookSecret?: Secret;
};
export type AppContext = FnContext;
/**
diff --git a/blog/types.ts b/blog/types.ts
index 53cce1fdc..ac8ca3d36 100644
--- a/blog/types.ts
+++ b/blog/types.ts
@@ -20,6 +20,15 @@ export interface Category {
}
export interface BlogPost {
+ /**
+ * @title AI-MANAGED POST (SPIRE)
+ * @description This post is automatically synchronized by Spire AI. Any manual edits made in this form will be overwritten during the next Spire synchronization.
+ */
+ spireWarning?: string;
+ /**
+ * @hide true
+ */
+ spirePostId?: string;
title: string;
excerpt: string;
/**
diff --git a/spire/manifest.gen.ts b/spire/manifest.gen.ts
index 0d922cb58..f199f8ea9 100644
--- a/spire/manifest.gen.ts
+++ b/spire/manifest.gen.ts
@@ -5,25 +5,27 @@
import * as $$$1 from "./loaders/BlogpostList.ts";
import * as $$$2 from "./loaders/BlogpostListing.ts";
import * as $$$0 from "./loaders/BlogPostPage.ts";
-import * as $$$$$$3 from "./sections/blocks/BlockImage.tsx";
-import * as $$$$$$4 from "./sections/blocks/Callout.tsx";
-import * as $$$$$$5 from "./sections/blocks/CardGroup.tsx";
-import * as $$$$$$6 from "./sections/blocks/Checklist.tsx";
-import * as $$$$$$7 from "./sections/blocks/Code.tsx";
-import * as $$$$$$8 from "./sections/blocks/Comparison.tsx";
-import * as $$$$$$9 from "./sections/blocks/Cta.tsx";
-import * as $$$$$$10 from "./sections/blocks/Divider.tsx";
-import * as $$$$$$11 from "./sections/blocks/Heading.tsx";
-import * as $$$$$$12 from "./sections/blocks/List.tsx";
-import * as $$$$$$13 from "./sections/blocks/Paragraph.tsx";
-import * as $$$$$$14 from "./sections/blocks/Quote.tsx";
-import * as $$$$$$15 from "./sections/blocks/Stat.tsx";
-import * as $$$$$$16 from "./sections/blocks/StatGroup.tsx";
-import * as $$$$$$17 from "./sections/blocks/Steps.tsx";
-import * as $$$$$$18 from "./sections/blocks/Video.tsx";
+import * as $$$$$$5 from "./sections/blocks/BlockImage.tsx";
+import * as $$$$$$6 from "./sections/blocks/Callout.tsx";
+import * as $$$$$$7 from "./sections/blocks/CardGroup.tsx";
+import * as $$$$$$8 from "./sections/blocks/Checklist.tsx";
+import * as $$$$$$9 from "./sections/blocks/Code.tsx";
+import * as $$$$$$10 from "./sections/blocks/Comparison.tsx";
+import * as $$$$$$11 from "./sections/blocks/Cta.tsx";
+import * as $$$$$$12 from "./sections/blocks/Divider.tsx";
+import * as $$$$$$13 from "./sections/blocks/Heading.tsx";
+import * as $$$$$$14 from "./sections/blocks/List.tsx";
+import * as $$$$$$15 from "./sections/blocks/Paragraph.tsx";
+import * as $$$$$$16 from "./sections/blocks/Quote.tsx";
+import * as $$$$$$17 from "./sections/blocks/Stat.tsx";
+import * as $$$$$$18 from "./sections/blocks/StatGroup.tsx";
+import * as $$$$$$19 from "./sections/blocks/Steps.tsx";
+import * as $$$$$$20 from "./sections/blocks/Video.tsx";
import * as $$$$$$0 from "./sections/Seo/SeoBlogPost.tsx";
import * as $$$$$$1 from "./sections/Seo/SeoBlogPostListing.tsx";
-import * as $$$$$$2 from "./sections/Template.tsx";
+import * as $$$$$$2 from "./sections/SpireDashboard.tsx";
+import * as $$$$$$3 from "./sections/SpirePendingApprovals.tsx";
+import * as $$$$$$4 from "./sections/Template.tsx";
const manifest = {
"loaders": {
@@ -32,25 +34,27 @@ const manifest = {
"spire/loaders/BlogPostPage.ts": $$$0,
},
"sections": {
- "spire/sections/blocks/BlockImage.tsx": $$$$$$3,
- "spire/sections/blocks/Callout.tsx": $$$$$$4,
- "spire/sections/blocks/CardGroup.tsx": $$$$$$5,
- "spire/sections/blocks/Checklist.tsx": $$$$$$6,
- "spire/sections/blocks/Code.tsx": $$$$$$7,
- "spire/sections/blocks/Comparison.tsx": $$$$$$8,
- "spire/sections/blocks/Cta.tsx": $$$$$$9,
- "spire/sections/blocks/Divider.tsx": $$$$$$10,
- "spire/sections/blocks/Heading.tsx": $$$$$$11,
- "spire/sections/blocks/List.tsx": $$$$$$12,
- "spire/sections/blocks/Paragraph.tsx": $$$$$$13,
- "spire/sections/blocks/Quote.tsx": $$$$$$14,
- "spire/sections/blocks/Stat.tsx": $$$$$$15,
- "spire/sections/blocks/StatGroup.tsx": $$$$$$16,
- "spire/sections/blocks/Steps.tsx": $$$$$$17,
- "spire/sections/blocks/Video.tsx": $$$$$$18,
+ "spire/sections/blocks/BlockImage.tsx": $$$$$$5,
+ "spire/sections/blocks/Callout.tsx": $$$$$$6,
+ "spire/sections/blocks/CardGroup.tsx": $$$$$$7,
+ "spire/sections/blocks/Checklist.tsx": $$$$$$8,
+ "spire/sections/blocks/Code.tsx": $$$$$$9,
+ "spire/sections/blocks/Comparison.tsx": $$$$$$10,
+ "spire/sections/blocks/Cta.tsx": $$$$$$11,
+ "spire/sections/blocks/Divider.tsx": $$$$$$12,
+ "spire/sections/blocks/Heading.tsx": $$$$$$13,
+ "spire/sections/blocks/List.tsx": $$$$$$14,
+ "spire/sections/blocks/Paragraph.tsx": $$$$$$15,
+ "spire/sections/blocks/Quote.tsx": $$$$$$16,
+ "spire/sections/blocks/Stat.tsx": $$$$$$17,
+ "spire/sections/blocks/StatGroup.tsx": $$$$$$18,
+ "spire/sections/blocks/Steps.tsx": $$$$$$19,
+ "spire/sections/blocks/Video.tsx": $$$$$$20,
"spire/sections/Seo/SeoBlogPost.tsx": $$$$$$0,
"spire/sections/Seo/SeoBlogPostListing.tsx": $$$$$$1,
- "spire/sections/Template.tsx": $$$$$$2,
+ "spire/sections/SpireDashboard.tsx": $$$$$$2,
+ "spire/sections/SpirePendingApprovals.tsx": $$$$$$3,
+ "spire/sections/Template.tsx": $$$$$$4,
},
"name": "spire",
"baseUrl": import.meta.url,
diff --git a/spire/sections/SpireDashboard.tsx b/spire/sections/SpireDashboard.tsx
new file mode 100644
index 000000000..4d87d68fe
--- /dev/null
+++ b/spire/sections/SpireDashboard.tsx
@@ -0,0 +1,64 @@
+import { useId } from "preact/hooks";
+
+export interface Props {
+ /**
+ * @title Spire Blog Slug
+ * @description Your Spire account blog slug (e.g., "miess")
+ */
+ blogSlug: string;
+ /**
+ * @title Spire Dashboard Base URL
+ * @description The base URL of your Spire instance (default: https://spire.blog)
+ */
+ spireUrl?: string;
+ /**
+ * @title Container Height
+ * @description Height of the embedded dashboard (default: 800px)
+ */
+ height?: string;
+}
+
+/**
+ * @title Spire Dashboard
+ * @description Embeds your Spire AI Blog Dashboard natively inside Deco Admin for seamless content generation and campaign management.
+ */
+export default function SpireDashboard({
+ blogSlug,
+ spireUrl = "https://spire.blog",
+ height = "800px",
+}: Props) {
+ const id = useId();
+
+ if (!blogSlug) {
+ return (
+
+
Configuration Required
+
+ Please configure your Spire Blog Slug in the section properties to embed the dashboard.
+
+
+ );
+ }
+
+ // Construct the embedded URL passing the embed=true search param
+ const embeddedUrl = `${spireUrl}/app/${blogSlug}?embed=true`;
+
+ return (
+
+ {/* Seamless Iframe container */}
+
+
+
+
+ );
+}
diff --git a/spire/sections/SpirePendingApprovals.tsx b/spire/sections/SpirePendingApprovals.tsx
new file mode 100644
index 000000000..dae54cf67
--- /dev/null
+++ b/spire/sections/SpirePendingApprovals.tsx
@@ -0,0 +1,189 @@
+import { useId } from "preact/hooks";
+
+export interface PendingGate {
+ id: string;
+ campaignId?: string;
+ postId?: string;
+ gateType: "seed_keyword" | "long_tails" | "brief" | "post_plan" | "product_review";
+ title: string;
+ reasoning: string;
+ createdAt: string;
+}
+
+export interface Props {
+ /**
+ * @title Spire Blog Slug
+ * @description Your Spire account blog slug (e.g., "miess")
+ */
+ blogSlug: string;
+ /**
+ * @title Spire Dashboard Base URL
+ * @description The base URL of your Spire instance (default: https://spire.blog)
+ */
+ spireUrl?: string;
+}
+
+export interface AsyncProps extends Props {
+ gates?: PendingGate[];
+}
+
+export const loader = async (
+ props: Props,
+ _req: Request,
+): Promise => {
+ const { blogSlug, spireUrl = "https://spire.blog" } = props;
+
+ if (!blogSlug) {
+ return { ...props, gates: [] };
+ }
+
+ try {
+ const response = await fetch(`${spireUrl}/api/blog/${blogSlug}/gates/pending`);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch pending gates: ${response.status}`);
+ }
+ const { gates } = await response.json();
+ return { ...props, gates: gates || [] };
+ } catch (error) {
+ console.warn("[SpirePendingApprovals] Error fetching gates from Spire (using fallback mock data in development):", error);
+
+ // Fallback/Mock data to ensure stunning UI rendering in development/testing
+ const mockGates: PendingGate[] = [
+ {
+ id: "gate-1",
+ gateType: "brief",
+ title: "E-commerce Strategy Brief for Bagaggio",
+ reasoning: "The campaign requires review of the SEO intent (Informational) targeting winter travel trends.",
+ createdAt: new Date(Date.now() - 3600000 * 2).toISOString(), // 2 hours ago
+ },
+ {
+ id: "gate-2",
+ gateType: "post_plan",
+ title: "Post Outline: 5 Best Travel Accessories for 2026",
+ reasoning: "Approve the generated outline gates and key terms before starting AI content drafting.",
+ createdAt: new Date(Date.now() - 3600000 * 24).toISOString(), // 1 day ago
+ }
+ ];
+ return { ...props, gates: mockGates };
+ }
+};
+
+export default function SpirePendingApprovals({
+ blogSlug,
+ spireUrl = "https://spire.blog",
+ gates = [],
+}: AsyncProps) {
+ const id = useId();
+
+ return (
+
+ {/* Decorative gradient blur background */}
+
+
+
+ {/* Header */}
+
+
+
+
+ Spire Integration
+
+
+ Status: Connected to {blogSlug}
+
+
+ Human-in-the-Loop Approvals
+
+
+ Approve generated outlines, keywords, and draft guidelines before content is published to your Deco blog.
+
+
+
+ Open Spire Dashboard
+
+
+
+
+
+
+ {/* Main Content */}
+
+ {gates.length === 0 ? (
+
+
+
+
+
All caught up!
+
+ There are no pending Human-in-the-Loop review steps for this blog at the moment.
+
+
+ ) : (
+
+ {gates.map((gate) => (
+
+ {/* Visual Accent Bar */}
+
+
+ {/* Gate Meta */}
+
+
+ {gate.gateType.replace("_", " ")}
+
+
+ {new Date(gate.createdAt).toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit"
+ })}
+
+
+
+ {/* Gate Details */}
+
+ {gate.title}
+
+
+ {gate.reasoning}
+
+
+ {/* Actions */}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
From 10bf7c3ffec79a56f6902e6b785240c6e387b23b Mon Sep 17 00:00:00 2001
From: yuriassuncx
Date: Wed, 27 May 2026 12:22:30 -0300
Subject: [PATCH 02/52] feat: implement Spire webhook for post synchronization
and add PendingApprovals section component
---
blog/actions/importSpirePost.ts | 163 ++++++++++++++++-------
spire/sections/SpirePendingApprovals.tsx | 40 +++---
2 files changed, 131 insertions(+), 72 deletions(-)
diff --git a/blog/actions/importSpirePost.ts b/blog/actions/importSpirePost.ts
index 7ef595c77..0cef3dc7a 100644
--- a/blog/actions/importSpirePost.ts
+++ b/blog/actions/importSpirePost.ts
@@ -1,14 +1,14 @@
import { AppContext } from "../mod.ts";
import { BlogPost } from "../types.ts";
-import { spirePostToBlogPost } from "../../spire/loaders/BlogPostPage.ts";
import { join } from "std/path/mod.ts";
import { SpirePost, Block } from "../../spire/types.ts";
+import { sanitizeHtml, sanitizeHref } from "../../spire/utils/sanitizeHtml.ts";
export interface Props {
postId: string;
postSlug: string;
blogSlug: string;
- event: "post.published" | "post.unpublished" | "post.saved";
+ event: "post.published" | "post.unpublished" | "event.saved" | "post.saved";
}
/**
@@ -18,31 +18,39 @@ export interface Props {
export default async function importSpirePost(
{ postSlug, blogSlug, event }: Props,
req: Request,
- _ctx: AppContext,
+ ctx: AppContext,
): Promise<{ success: boolean; path?: string; message?: string }> {
try {
// 1. Security Authorization Header Verification
const expectedSecret =
- (typeof _ctx.spireWebhookSecret === "string"
- ? _ctx.spireWebhookSecret
- : _ctx.spireWebhookSecret?.get?.()) ||
+ (typeof ctx.spireWebhookSecret === "string"
+ ? ctx.spireWebhookSecret
+ : ctx.spireWebhookSecret?.get?.()) ||
Deno.env.get("SPIRE_WEBHOOK_SECRET");
- if (expectedSecret) {
- const authHeader = req.headers.get("Authorization");
- if (!authHeader || authHeader !== `Bearer ${expectedSecret}`) {
- return {
- success: false,
- message: "Unauthorized: Webhook token verification failed."
- };
- }
- } else {
- console.warn("[Webhook] Warning: SPIRE_WEBHOOK_SECRET is not configured. Webhook request authorized without verification (development mode).");
+ if (!expectedSecret) {
+ return {
+ success: false,
+ message: "Unauthorized: webhook secret not configured."
+ };
+ }
+ const authHeader = req.headers.get("Authorization");
+ if (!authHeader || authHeader !== `Bearer ${expectedSecret}`) {
+ return {
+ success: false,
+ message: "Unauthorized: Webhook token verification failed."
+ };
}
+ // 2. Validate and sanitize postSlug to prevent directory/path traversal
+ if (!postSlug || typeof postSlug !== "string" || !/^[a-zA-Z0-9_-]+$/.test(postSlug)) {
+ throw new Error("Invalid post slug format");
+ }
+ const sanitizedSlug = postSlug;
+
if (event === "post.unpublished") {
// Handle unpublishing/deletion: Remove block file
const blocksDir = join(Deno.cwd(), ".deco", "blocks", "collections", "blog", "posts");
- const filePath = join(blocksDir, `${postSlug}.json`);
+ const filePath = join(blocksDir, `${sanitizedSlug}.json`);
try {
await Deno.remove(filePath);
console.info(`[Webhook] Successfully removed unpublished post at: ${filePath}`);
@@ -55,8 +63,24 @@ export default async function importSpirePost(
}
}
- // 2. Fetch full post content from Spire API
- const response = await fetch(`https://spire.blog/api/blog/${blogSlug}/posts/${postSlug}`);
+ // 3. Fetch full post content from Spire API with URL encoding and AbortController timeout
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout
+ let response;
+ try {
+ response = await fetch(
+ `https://spire.blog/api/blog/${encodeURIComponent(blogSlug)}/posts/${encodeURIComponent(postSlug)}`,
+ { signal: controller.signal }
+ );
+ } catch (err) {
+ if (err instanceof DOMException && err.name === "AbortError") {
+ throw new Error("Request to Spire API timed out");
+ }
+ throw err;
+ } finally {
+ clearTimeout(timeoutId);
+ }
+
if (!response.ok) {
throw new Error(`Failed to fetch post from Spire: ${response.status} ${response.statusText}`);
}
@@ -66,29 +90,44 @@ export default async function importSpirePost(
throw new Error("No post content returned from Spire API");
}
- // 3. Convert SpirePost schema to BlogPost
- const baseBlogPost = spirePostToBlogPost(post, {});
-
// 4. Compile Spire content blocks into a clean semantic HTML string for Deco Admin WYSIWYG
const htmlContent = compileBlocksToHtml(post.version.blocks);
+ // 5. Convert SpirePost schema to native BlogPost
const blogPost: BlogPost = {
- ...baseBlogPost,
+ id: post.id,
+ title: post.version.title,
+ excerpt: post.version.description,
+ image: post.version.imageUrl,
+ alt: post.version.title,
+ authors: post.authors.map((a) => ({
+ name: a.name,
+ email: "",
+ avatar: a.avatarUrl ?? undefined,
+ })),
+ categories: [],
+ date: post.publishedAt ?? "",
+ slug: post.slug,
+ seo: {
+ title: post.version.metaTitle,
+ description: post.version.metaDescription,
+ image: post.version.imageUrl,
+ },
content: htmlContent,
spirePostId: post.id,
spireWarning: "This post is automatically synchronized by Spire. Any manual edits made in this form will be overwritten during the next sync.",
};
- // 5. Build Deco Block Resolvable
+ // 6. Build Deco Block Resolvable
const resolvable = {
__resolveType: "blog/loaders/Blogpost.ts",
post: blogPost,
};
- // 6. Write natively to filesystem as local JSON block (.deco/blocks/collections/blog/posts/.json)
+ // 7. Write natively to filesystem as local JSON block (.deco/blocks/collections/blog/posts/.json)
const blocksDir = join(Deno.cwd(), ".deco", "blocks", "collections", "blog", "posts");
await Deno.mkdir(blocksDir, { recursive: true });
- const filePath = join(blocksDir, `${postSlug}.json`);
+ const filePath = join(blocksDir, `${sanitizedSlug}.json`);
await Deno.writeTextFile(filePath, JSON.stringify(resolvable, null, 2));
console.info(`[Webhook] Successfully imported post and saved to ${filePath}`);
@@ -106,6 +145,7 @@ export default async function importSpirePost(
};
}
}
+
export interface SpireBlockContent {
html?: string;
text?: string;
@@ -127,7 +167,33 @@ export interface SpireBlockContent {
}
/**
- * Compiles Spire's modular block arrays to semantic standard HTML
+ * Helper to escape HTML in plain text strings
+ */
+function escapeHtml(value?: string): string {
+ if (!value) return "";
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+/**
+ * Helper to robustly parse JSON strings with fallbacks
+ */
+function safeJsonParse(value: unknown, fallback: T): T {
+ if (typeof value !== "string") return fallback;
+ try {
+ return JSON.parse(value) as T;
+ } catch (e) {
+ console.error("[Webhook] JSON parse error:", e);
+ return fallback;
+ }
+}
+
+/**
+ * Compiles Spire's modular block arrays to semantic standard HTML with XSS prevention
*/
function compileBlocksToHtml(blocks?: Block[]): string {
if (!blocks || !Array.isArray(blocks)) return "";
@@ -138,59 +204,54 @@ function compileBlocksToHtml(blocks?: Block[]): string {
const content = (block.content || {}) as SpireBlockContent;
switch (block.type) {
case "paragraph":
- return content.html ? content.html : `${content.text || ""}
`;
+ return content.html ? sanitizeHtml(content.html) : `${escapeHtml(content.text)}
`;
case "heading": {
const level = content.level || "2";
- return `${content.text || ""} `;
+ return `${escapeHtml(content.text)} `;
}
case "list": {
const style = content.style === "ordered" ? "ol" : "ul";
const items = Array.isArray(content.items)
? content.items
- : typeof content.items === "string"
- ? JSON.parse(content.items) as string[]
- : [];
- const listItems = items.map((item: string) => `${item} `).join("");
+ : safeJsonParse(content.items, []);
+ const listItems = items.map((item: string) => `${sanitizeHtml(item)} `).join("");
return `<${style}>${listItems}${style}>`;
}
case "divider":
return " ";
case "quote":
- return `${content.quote || content.text || ""}
${content.attribution ? `${content.attribution} ` : ""} `;
+ return `${escapeHtml(content.quote || content.text)}
${content.attribution ? `${escapeHtml(content.attribution)} ` : ""} `;
case "callout": {
const variant = content.variant || "info";
- return `${content.title || ""} ${content.body || ""}
`;
+ return `${escapeHtml(content.title)} ${sanitizeHtml(content.body)}
`;
}
case "checklist": {
const checkItems = Array.isArray(content.items)
? content.items
- : typeof content.items === "string"
- ? JSON.parse(content.items) as string[]
- : [];
- const checkList = checkItems.map((item: string) => ` ${item} `).join("");
- return `${content.title ? `${content.title} ` : ""}${checkList} `;
+ : safeJsonParse(content.items, []);
+ const checkList = checkItems.map((item: string) => ` ${sanitizeHtml(item)} `).join("");
+ return `${content.title ? `${escapeHtml(content.title)} ` : ""}${checkList} `;
}
case "steps": {
const stepItems = Array.isArray(content.steps)
? content.steps
- : typeof content.steps === "string"
- ? JSON.parse(content.steps) as Array<{ title?: string; description?: string }>
- : [];
- const stepList = stepItems.map((step: { title?: string; description?: string }, index: number) => `${index + 1}. ${step.title || ""} ${step.description ? `${step.description}
` : ""} `).join("");
- return `${content.title ? `${content.title} ` : ""}${stepList} `;
+ : safeJsonParse>(content.steps, []);
+ const stepList = stepItems.map((step: { title?: string; description?: string }, index: number) => `${index + 1}. ${escapeHtml(step.title)} ${step.description ? `${sanitizeHtml(step.description)}
` : ""} `).join("");
+ return `${content.title ? `${escapeHtml(content.title)} ` : ""}${stepList} `;
}
case "image":
- return ` ${content.caption ? `${content.caption} ` : ""} `;
+ return ` ${content.caption ? `${escapeHtml(content.caption)} ` : ""} `;
case "video":
- return ` ${content.caption ? `${content.caption} ` : ""} `;
+ return ` ${content.caption ? `${escapeHtml(content.caption)} ` : ""} `;
case "code":
- return `${content.code || ""} `;
+ return `${escapeHtml(content.code)} `;
case "cta":
- return ``;
+ return ``;
default:
- if (content.html) return content.html;
- if (content.text) return `${content.text}
`;
+ if (content.html) return sanitizeHtml(content.html);
+ if (content.text) return `${escapeHtml(content.text)}
`;
return "";
}
}).join("\n");
}
+
diff --git a/spire/sections/SpirePendingApprovals.tsx b/spire/sections/SpirePendingApprovals.tsx
index dae54cf67..272cf24e3 100644
--- a/spire/sections/SpirePendingApprovals.tsx
+++ b/spire/sections/SpirePendingApprovals.tsx
@@ -37,34 +37,32 @@ export const loader = async (
return { ...props, gates: [] };
}
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 8000); // 8 seconds timeout
+
try {
- const response = await fetch(`${spireUrl}/api/blog/${blogSlug}/gates/pending`);
+ const response = await fetch(
+ `${spireUrl}/api/blog/${encodeURIComponent(blogSlug)}/gates/pending`,
+ {
+ signal: controller.signal,
+ headers: {
+ "Accept": "application/json; charset=utf-8",
+ },
+ }
+ );
+ clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`Failed to fetch pending gates: ${response.status}`);
}
const { gates } = await response.json();
return { ...props, gates: gates || [] };
} catch (error) {
- console.warn("[SpirePendingApprovals] Error fetching gates from Spire (using fallback mock data in development):", error);
-
- // Fallback/Mock data to ensure stunning UI rendering in development/testing
- const mockGates: PendingGate[] = [
- {
- id: "gate-1",
- gateType: "brief",
- title: "E-commerce Strategy Brief for Bagaggio",
- reasoning: "The campaign requires review of the SEO intent (Informational) targeting winter travel trends.",
- createdAt: new Date(Date.now() - 3600000 * 2).toISOString(), // 2 hours ago
- },
- {
- id: "gate-2",
- gateType: "post_plan",
- title: "Post Outline: 5 Best Travel Accessories for 2026",
- reasoning: "Approve the generated outline gates and key terms before starting AI content drafting.",
- createdAt: new Date(Date.now() - 3600000 * 24).toISOString(), // 1 day ago
- }
- ];
- return { ...props, gates: mockGates };
+ clearTimeout(timeoutId);
+ if (error instanceof DOMException && error.name === "AbortError") {
+ console.warn("[SpirePendingApprovals] Request timed out while fetching pending gates.");
+ }
+ console.error("[SpirePendingApprovals] Error fetching gates from Spire:", error);
+ return { ...props, gates: [] };
}
};
From 03b88f733282b4dd4ac8a0097c7d02203035b966 Mon Sep 17 00:00:00 2001
From: yuriassuncx
Date: Wed, 27 May 2026 12:26:43 -0300
Subject: [PATCH 03/52] feat: secure and URL-encode SpireDashboard embedded
iframe origin and permissions
---
spire/sections/SpireDashboard.tsx | 30 +++++++++++++++++++++++++++---
1 file changed, 27 insertions(+), 3 deletions(-)
diff --git a/spire/sections/SpireDashboard.tsx b/spire/sections/SpireDashboard.tsx
index 4d87d68fe..86847e6c3 100644
--- a/spire/sections/SpireDashboard.tsx
+++ b/spire/sections/SpireDashboard.tsx
@@ -18,6 +18,28 @@ export interface Props {
height?: string;
}
+/**
+ * Validate that the spireUrl is a trusted origin (spire.blog, subdomains, or local dev)
+ */
+function getSafeSpireUrl(urlStr: string): string {
+ try {
+ const parsed = new URL(urlStr);
+ const isTrusted = (
+ parsed.protocol === "https:" &&
+ (parsed.hostname === "spire.blog" || parsed.hostname.endsWith(".spire.blog"))
+ ) || (
+ parsed.protocol === "http:" &&
+ parsed.hostname === "localhost"
+ );
+ if (isTrusted) {
+ return urlStr;
+ }
+ } catch {
+ // Fall back to default on invalid URL
+ }
+ return "https://spire.blog";
+}
+
/**
* @title Spire Dashboard
* @description Embeds your Spire AI Blog Dashboard natively inside Deco Admin for seamless content generation and campaign management.
@@ -40,8 +62,10 @@ export default function SpireDashboard({
);
}
- // Construct the embedded URL passing the embed=true search param
- const embeddedUrl = `${spireUrl}/app/${blogSlug}?embed=true`;
+ const safeSpireUrl = getSafeSpireUrl(spireUrl);
+
+ // Construct the embedded URL passing the embed=true search param and encoding the slug
+ const embeddedUrl = `${safeSpireUrl}/app/${encodeURIComponent(blogSlug)}?embed=true`;
return (
From 7ccde832bd93db2a6dc615b87093884095510903 Mon Sep 17 00:00:00 2001
From: yuriassuncx
Date: Wed, 27 May 2026 12:28:11 -0300
Subject: [PATCH 04/52] style: auto-format and optimize all files in the spire
module with deno fmt
---
spire/sections/SpireDashboard.tsx | 14 +-
spire/sections/SpirePendingApprovals.tsx | 182 ++++++++++++++---------
2 files changed, 119 insertions(+), 77 deletions(-)
diff --git a/spire/sections/SpireDashboard.tsx b/spire/sections/SpireDashboard.tsx
index 86847e6c3..6450adc0f 100644
--- a/spire/sections/SpireDashboard.tsx
+++ b/spire/sections/SpireDashboard.tsx
@@ -26,7 +26,8 @@ function getSafeSpireUrl(urlStr: string): string {
const parsed = new URL(urlStr);
const isTrusted = (
parsed.protocol === "https:" &&
- (parsed.hostname === "spire.blog" || parsed.hostname.endsWith(".spire.blog"))
+ (parsed.hostname === "spire.blog" ||
+ parsed.hostname.endsWith(".spire.blog"))
) || (
parsed.protocol === "http:" &&
parsed.hostname === "localhost"
@@ -56,7 +57,8 @@ export default function SpireDashboard({
Configuration Required
- Please configure your Spire Blog Slug in the section properties to embed the dashboard.
+ Please configure your Spire Blog Slug in the section properties to
+ embed the dashboard.
);
@@ -65,11 +67,13 @@ export default function SpireDashboard({
const safeSpireUrl = getSafeSpireUrl(spireUrl);
// Construct the embedded URL passing the embed=true search param and encoding the slug
- const embeddedUrl = `${safeSpireUrl}/app/${encodeURIComponent(blogSlug)}?embed=true`;
+ const embeddedUrl = `${safeSpireUrl}/app/${
+ encodeURIComponent(blogSlug)
+ }?embed=true`;
return (
-
diff --git a/spire/sections/SpirePendingApprovals.tsx b/spire/sections/SpirePendingApprovals.tsx
index 272cf24e3..c043afa62 100644
--- a/spire/sections/SpirePendingApprovals.tsx
+++ b/spire/sections/SpirePendingApprovals.tsx
@@ -4,7 +4,12 @@ export interface PendingGate {
id: string;
campaignId?: string;
postId?: string;
- gateType: "seed_keyword" | "long_tails" | "brief" | "post_plan" | "product_review";
+ gateType:
+ | "seed_keyword"
+ | "long_tails"
+ | "brief"
+ | "post_plan"
+ | "product_review";
title: string;
reasoning: string;
createdAt: string;
@@ -32,7 +37,7 @@ export const loader = async (
_req: Request,
): Promise
=> {
const { blogSlug, spireUrl = "https://spire.blog" } = props;
-
+
if (!blogSlug) {
return { ...props, gates: [] };
}
@@ -48,7 +53,7 @@ export const loader = async (
headers: {
"Accept": "application/json; charset=utf-8",
},
- }
+ },
);
clearTimeout(timeoutId);
if (!response.ok) {
@@ -59,9 +64,14 @@ export const loader = async (
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof DOMException && error.name === "AbortError") {
- console.warn("[SpirePendingApprovals] Request timed out while fetching pending gates.");
+ console.warn(
+ "[SpirePendingApprovals] Request timed out while fetching pending gates.",
+ );
}
- console.error("[SpirePendingApprovals] Error fetching gates from Spire:", error);
+ console.error(
+ "[SpirePendingApprovals] Error fetching gates from Spire:",
+ error,
+ );
return { ...props, gates: [] };
}
};
@@ -74,8 +84,8 @@ export default function SpirePendingApprovals({
const id = useId();
return (
-
@@ -91,13 +101,16 @@ export default function SpirePendingApprovals({
Spire Integration
- Status: Connected to {blogSlug}
+
+ Status: Connected to {blogSlug}
+
Human-in-the-Loop Approvals
- Approve generated outlines, keywords, and draft guidelines before content is published to your Deco blog.
+ Approve generated outlines, keywords, and draft guidelines before
+ content is published to your Deco blog.
Open Spire Dashboard
-
-
+
+
{/* Main Content */}
- {gates.length === 0 ? (
-
-
-
-
-
All caught up!
-
- There are no pending Human-in-the-Loop review steps for this blog at the moment.
-
-
- ) : (
-
- {gates.map((gate) => (
-
+
- {/* Visual Accent Bar */}
-
+
+
+
+ All caught up!
+
+
+ There are no pending Human-in-the-Loop review steps for this
+ blog at the moment.
+
+
+ )
+ : (
+
+ {gates.map((gate) => (
+
+ {/* Visual Accent Bar */}
+
- {/* Gate Meta */}
-
-
- {gate.gateType.replace("_", " ")}
-
-
- {new Date(gate.createdAt).toLocaleDateString(undefined, {
- month: "short",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit"
- })}
-
-
+ {/* Gate Meta */}
+
+
+ {gate.gateType.replace("_", " ")}
+
+
+ {new Date(gate.createdAt).toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+
+
- {/* Gate Details */}
-
- {gate.title}
-
-
- {gate.reasoning}
-
+ {/* Gate Details */}
+
+ {gate.title}
+
+
+ {gate.reasoning}
+
- {/* Actions */}
-
-
- ))}
-
- )}
+ ))}
+
+ )}
);
From c4af58c0454de23bb80375982cd27755538f0268 Mon Sep 17 00:00:00 2001
From: yuriassuncx
Date: Wed, 27 May 2026 12:38:28 -0300
Subject: [PATCH 05/52] style: auto-format all files in the blog module with
deno fmt
---
blog/actions/importSpirePost.ts | 168 +++++++++++++++++++++++---------
1 file changed, 121 insertions(+), 47 deletions(-)
diff --git a/blog/actions/importSpirePost.ts b/blog/actions/importSpirePost.ts
index 0cef3dc7a..341ee7e0a 100644
--- a/blog/actions/importSpirePost.ts
+++ b/blog/actions/importSpirePost.ts
@@ -1,8 +1,8 @@
import { AppContext } from "../mod.ts";
import { BlogPost } from "../types.ts";
import { join } from "std/path/mod.ts";
-import { SpirePost, Block } from "../../spire/types.ts";
-import { sanitizeHtml, sanitizeHref } from "../../spire/utils/sanitizeHtml.ts";
+import { Block, SpirePost } from "../../spire/types.ts";
+import { sanitizeHref, sanitizeHtml } from "../../spire/utils/sanitizeHtml.ts";
export interface Props {
postId: string;
@@ -22,42 +22,56 @@ export default async function importSpirePost(
): Promise<{ success: boolean; path?: string; message?: string }> {
try {
// 1. Security Authorization Header Verification
- const expectedSecret =
- (typeof ctx.spireWebhookSecret === "string"
- ? ctx.spireWebhookSecret
- : ctx.spireWebhookSecret?.get?.()) ||
+ const expectedSecret = (typeof ctx.spireWebhookSecret === "string"
+ ? ctx.spireWebhookSecret
+ : ctx.spireWebhookSecret?.get?.()) ||
Deno.env.get("SPIRE_WEBHOOK_SECRET");
if (!expectedSecret) {
return {
success: false,
- message: "Unauthorized: webhook secret not configured."
+ message: "Unauthorized: webhook secret not configured.",
};
}
const authHeader = req.headers.get("Authorization");
if (!authHeader || authHeader !== `Bearer ${expectedSecret}`) {
- return {
- success: false,
- message: "Unauthorized: Webhook token verification failed."
+ return {
+ success: false,
+ message: "Unauthorized: Webhook token verification failed.",
};
}
// 2. Validate and sanitize postSlug to prevent directory/path traversal
- if (!postSlug || typeof postSlug !== "string" || !/^[a-zA-Z0-9_-]+$/.test(postSlug)) {
+ if (
+ !postSlug || typeof postSlug !== "string" ||
+ !/^[a-zA-Z0-9_-]+$/.test(postSlug)
+ ) {
throw new Error("Invalid post slug format");
}
const sanitizedSlug = postSlug;
if (event === "post.unpublished") {
// Handle unpublishing/deletion: Remove block file
- const blocksDir = join(Deno.cwd(), ".deco", "blocks", "collections", "blog", "posts");
+ const blocksDir = join(
+ Deno.cwd(),
+ ".deco",
+ "blocks",
+ "collections",
+ "blog",
+ "posts",
+ );
const filePath = join(blocksDir, `${sanitizedSlug}.json`);
try {
await Deno.remove(filePath);
- console.info(`[Webhook] Successfully removed unpublished post at: ${filePath}`);
+ console.info(
+ `[Webhook] Successfully removed unpublished post at: ${filePath}`,
+ );
return { success: true, message: "Post unpublished successfully" };
} catch (err) {
if (err instanceof Deno.errors.NotFound) {
- return { success: true, message: "Post already unpublished or not found" };
+ return {
+ success: true,
+ message: "Post already unpublished or not found",
+ };
}
throw err;
}
@@ -65,12 +79,15 @@ export default async function importSpirePost(
// 3. Fetch full post content from Spire API with URL encoding and AbortController timeout
const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout
+ const timeoutId = setTimeout(() =>
+ controller.abort(), 5000); // 5s timeout
let response;
try {
response = await fetch(
- `https://spire.blog/api/blog/${encodeURIComponent(blogSlug)}/posts/${encodeURIComponent(postSlug)}`,
- { signal: controller.signal }
+ `https://spire.blog/api/blog/${encodeURIComponent(blogSlug)}/posts/${
+ encodeURIComponent(postSlug)
+ }`,
+ { signal: controller.signal },
);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
@@ -82,7 +99,9 @@ export default async function importSpirePost(
}
if (!response.ok) {
- throw new Error(`Failed to fetch post from Spire: ${response.status} ${response.statusText}`);
+ throw new Error(
+ `Failed to fetch post from Spire: ${response.status} ${response.statusText}`,
+ );
}
const { post } = (await response.json()) as { post?: SpirePost };
@@ -115,7 +134,8 @@ export default async function importSpirePost(
},
content: htmlContent,
spirePostId: post.id,
- spireWarning: "This post is automatically synchronized by Spire. Any manual edits made in this form will be overwritten during the next sync.",
+ spireWarning:
+ "This post is automatically synchronized by Spire. Any manual edits made in this form will be overwritten during the next sync.",
};
// 6. Build Deco Block Resolvable
@@ -125,23 +145,32 @@ export default async function importSpirePost(
};
// 7. Write natively to filesystem as local JSON block (.deco/blocks/collections/blog/posts/.json)
- const blocksDir = join(Deno.cwd(), ".deco", "blocks", "collections", "blog", "posts");
+ const blocksDir = join(
+ Deno.cwd(),
+ ".deco",
+ "blocks",
+ "collections",
+ "blog",
+ "posts",
+ );
await Deno.mkdir(blocksDir, { recursive: true });
const filePath = join(blocksDir, `${sanitizedSlug}.json`);
await Deno.writeTextFile(filePath, JSON.stringify(resolvable, null, 2));
- console.info(`[Webhook] Successfully imported post and saved to ${filePath}`);
+ console.info(
+ `[Webhook] Successfully imported post and saved to ${filePath}`,
+ );
- return {
- success: true,
- path: filePath,
- message: "Post imported and compiled successfully"
+ return {
+ success: true,
+ path: filePath,
+ message: "Post imported and compiled successfully",
};
} catch (error) {
console.error("[Webhook] Error importing Spire post:", error);
- return {
- success: false,
- message: error instanceof Error ? error.message : "Internal Server Error"
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : "Internal Server Error",
};
}
}
@@ -198,55 +227,101 @@ function safeJsonParse(value: unknown, fallback: T): T {
function compileBlocksToHtml(blocks?: Block[]): string {
if (!blocks || !Array.isArray(blocks)) return "";
- const sorted = [...blocks].sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
+ const sorted = [...blocks].sort((a, b) =>
+ (a.position ?? 0) - (b.position ?? 0)
+ );
return sorted.map((block) => {
const content = (block.content || {}) as SpireBlockContent;
switch (block.type) {
case "paragraph":
- return content.html ? sanitizeHtml(content.html) : `${escapeHtml(content.text)}
`;
+ return content.html
+ ? sanitizeHtml(content.html)
+ : `${escapeHtml(content.text)}
`;
case "heading": {
const level = content.level || "2";
return `${escapeHtml(content.text)} `;
}
case "list": {
const style = content.style === "ordered" ? "ol" : "ul";
- const items = Array.isArray(content.items)
- ? content.items
+ const items = Array.isArray(content.items)
+ ? content.items
: safeJsonParse(content.items, []);
- const listItems = items.map((item: string) => `${sanitizeHtml(item)} `).join("");
+ const listItems = items.map((item: string) =>
+ `${sanitizeHtml(item)} `
+ ).join("");
return `<${style}>${listItems}${style}>`;
}
case "divider":
return " ";
case "quote":
- return `${escapeHtml(content.quote || content.text)}
${content.attribution ? `${escapeHtml(content.attribution)} ` : ""} `;
+ return `${
+ escapeHtml(content.quote || content.text)
+ }
${
+ content.attribution
+ ? `${escapeHtml(content.attribution)} `
+ : ""
+ } `;
case "callout": {
const variant = content.variant || "info";
- return `${escapeHtml(content.title)} ${sanitizeHtml(content.body)}
`;
+ return `${
+ escapeHtml(content.title)
+ } ${sanitizeHtml(content.body)}
`;
}
case "checklist": {
- const checkItems = Array.isArray(content.items)
- ? content.items
+ const checkItems = Array.isArray(content.items)
+ ? content.items
: safeJsonParse(content.items, []);
- const checkList = checkItems.map((item: string) => ` ${sanitizeHtml(item)} `).join("");
- return `${content.title ? `${escapeHtml(content.title)} ` : ""}${checkList} `;
+ const checkList = checkItems.map((item: string) =>
+ ` ${sanitizeHtml(item)} `
+ ).join("");
+ return `${
+ content.title ? `${escapeHtml(content.title)} ` : ""
+ }${checkList} `;
}
case "steps": {
const stepItems = Array.isArray(content.steps)
? content.steps
- : safeJsonParse>(content.steps, []);
- const stepList = stepItems.map((step: { title?: string; description?: string }, index: number) => `${index + 1}. ${escapeHtml(step.title)} ${step.description ? `${sanitizeHtml(step.description)}
` : ""} `).join("");
- return `${content.title ? `${escapeHtml(content.title)} ` : ""}${stepList} `;
+ : safeJsonParse>(
+ content.steps,
+ [],
+ );
+ const stepList = stepItems.map((
+ step: { title?: string; description?: string },
+ index: number,
+ ) =>
+ `${index + 1}. ${escapeHtml(step.title)} ${
+ step.description ? `${sanitizeHtml(step.description)}
` : ""
+ } `
+ ).join("");
+ return `${
+ content.title ? `${escapeHtml(content.title)} ` : ""
+ }${stepList} `;
}
case "image":
- return ` ${content.caption ? `${escapeHtml(content.caption)} ` : ""} `;
+ return ` ${
+ content.caption
+ ? `${escapeHtml(content.caption)} `
+ : ""
+ } `;
case "video":
- return ` ${content.caption ? `${escapeHtml(content.caption)} ` : ""} `;
+ return ` ${
+ content.caption
+ ? `${escapeHtml(content.caption)} `
+ : ""
+ } `;
case "code":
- return `${escapeHtml(content.code)} `;
+ return `${
+ escapeHtml(content.code)
+ } `;
case "cta":
- return ``;
+ return ``;
default:
if (content.html) return sanitizeHtml(content.html);
if (content.text) return `${escapeHtml(content.text)}
`;
@@ -254,4 +329,3 @@ function compileBlocksToHtml(blocks?: Block[]): string {
}
}).join("\n");
}
-
From d0fbc96b66676260c5f78dcff6d757f7882bc779 Mon Sep 17 00:00:00 2001
From: yuriassuncx
Date: Wed, 27 May 2026 12:48:19 -0300
Subject: [PATCH 06/52] feat: implement Deco-Spire quick approvals action and
bidirectional manual sync watcher
---
blog/actions/importSpirePost.ts | 3 +
blog/actions/resolveSpireGate.ts | 73 +++++++++++++
blog/mod.ts | 126 +++++++++++++++++++++++
spire/sections/SpirePendingApprovals.tsx | 68 +++++++++++-
4 files changed, 266 insertions(+), 4 deletions(-)
create mode 100644 blog/actions/resolveSpireGate.ts
diff --git a/blog/actions/importSpirePost.ts b/blog/actions/importSpirePost.ts
index 341ee7e0a..93c26d519 100644
--- a/blog/actions/importSpirePost.ts
+++ b/blog/actions/importSpirePost.ts
@@ -144,6 +144,9 @@ export default async function importSpirePost(
post: blogPost,
};
+ // Set transient sync flag to prevent loop feedback in Deno watchFs
+ Deno.env.set(`SPIRE_SYNC_ACTIVE_${sanitizedSlug}`, Date.now().toString());
+
// 7. Write natively to filesystem as local JSON block (.deco/blocks/collections/blog/posts/.json)
const blocksDir = join(
Deno.cwd(),
diff --git a/blog/actions/resolveSpireGate.ts b/blog/actions/resolveSpireGate.ts
new file mode 100644
index 000000000..4703c0a6c
--- /dev/null
+++ b/blog/actions/resolveSpireGate.ts
@@ -0,0 +1,73 @@
+import { AppContext } from "../mod.ts";
+
+export interface Props {
+ gateId: string;
+ gateType: "campaign" | "post";
+ blogSlug: string;
+ campaignId?: string;
+ postId?: string;
+}
+
+/**
+ * @title Resolve Spire Gate
+ * @description Resolves (approves) a pending Human-in-the-Loop review step directly from Deco Admin.
+ */
+export default async function resolveSpireGate(
+ props: Props,
+ _req: Request,
+ ctx: AppContext,
+): Promise<{ success: boolean; message?: string }> {
+ const { gateId, gateType, blogSlug, campaignId, postId } = props;
+
+ // 1. Retrieve the Spire Webhook Secret configured on the Blog app state
+ const expectedSecret =
+ (typeof ctx.spireWebhookSecret === "string"
+ ? ctx.spireWebhookSecret
+ : ctx.spireWebhookSecret?.get?.()) ||
+ Deno.env.get("SPIRE_WEBHOOK_SECRET");
+
+ if (!expectedSecret) {
+ return {
+ success: false,
+ message: "SPIRE_WEBHOOK_SECRET is not configured on your Deco site.",
+ };
+ }
+
+ // 2. Fetch the target Spire Base URL (default: https://spire.blog)
+ const spireUrl = Deno.env.get("SPIRE_URL") || "https://spire.blog";
+
+ try {
+ const response = await fetch(
+ `${spireUrl}/api/blog/${encodeURIComponent(blogSlug)}/gates/resolve`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${expectedSecret}`,
+ },
+ body: JSON.stringify({
+ gateId,
+ gateType,
+ campaignId,
+ postId,
+ }),
+ },
+ );
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ return {
+ success: false,
+ message: `Failed to resolve Spire gate: ${errorText}`,
+ };
+ }
+
+ return { success: true, message: "Gate approved successfully!" };
+ } catch (error) {
+ console.error("[DecoResolveSpireGate] Error:", error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : "Internal Server Error",
+ };
+ }
+}
diff --git a/blog/mod.ts b/blog/mod.ts
index e1a6641b2..f00e342fc 100644
--- a/blog/mod.ts
+++ b/blog/mod.ts
@@ -2,6 +2,9 @@ import manifest, { Manifest } from "./manifest.gen.ts";
import { Secret } from "../website/loaders/secret.ts";
import { PreviewContainer } from "../utils/preview.tsx";
import { type App, type FnContext } from "@deco/deco";
+import { join } from "std/path/mod.ts";
+import { BlogPost } from "./types.ts";
+
export type State = {
/**
* @title Page Slug
@@ -15,6 +18,128 @@ export type State = {
spireWebhookSecret?: Secret;
};
export type AppContext = FnContext;
+
+let isWatcherStarted = false;
+
+function startBlogWatcher(state: State) {
+ if (isWatcherStarted) return;
+ isWatcherStarted = true;
+
+ const blocksDir = join(
+ Deno.cwd(),
+ ".deco",
+ "blocks",
+ "collections",
+ "blog",
+ "posts",
+ );
+
+ (async () => {
+ try {
+ // Ensure the posts directory exists
+ try {
+ await Deno.mkdir(blocksDir, { recursive: true });
+ } catch {
+ // Ignore if already exists
+ }
+
+ const watcher = Deno.watchFs(blocksDir);
+ console.info(
+ `[BlogWatcher] Watching Deco CMS posts for manual edits in: ${blocksDir}`,
+ );
+
+ for await (const event of watcher) {
+ if (event.kind === "modify" || event.kind === "create") {
+ for (const path of event.paths) {
+ if (path.endsWith(".json")) {
+ try {
+ // Read and parse modified JSON resolvable file
+ const text = await Deno.readTextFile(path);
+ const data = JSON.parse(text);
+
+ if (
+ data?.__resolveType === "blog/loaders/Blogpost.ts" &&
+ data?.post
+ ) {
+ const post = data.post as BlogPost;
+
+ // 1. Skip posts that don't belong to Spire
+ if (!post.spirePostId) continue;
+
+ // 2. Prevent feedback loop using transient environment sync flag check
+ const syncActive = Deno.env.get(
+ `SPIRE_SYNC_ACTIVE_${post.slug}`,
+ );
+ if (syncActive) {
+ const diff = Date.now() - parseInt(syncActive, 10);
+ if (diff < 8000) { // 8-second window
+ continue;
+ }
+ }
+
+ // 3. Sync manual content updates back to Spire
+ await notifySpireOfManualUpdate(post, state);
+ }
+ } catch {
+ // Ignore transient JSON parse or read conflicts on fast saves
+ }
+ }
+ }
+ }
+ }
+ } catch (err) {
+ console.error("[BlogWatcher] Error in watchFs loop:", err);
+ // Restart watcher in case of unexpected file system handle drops
+ isWatcherStarted = false;
+ setTimeout(() => startBlogWatcher(state), 5000);
+ }
+ })();
+}
+
+async function notifySpireOfManualUpdate(post: BlogPost, state: State) {
+ const expectedSecret =
+ (typeof state.spireWebhookSecret === "string"
+ ? state.spireWebhookSecret
+ : state.spireWebhookSecret?.get?.()) ||
+ Deno.env.get("SPIRE_WEBHOOK_SECRET");
+
+ if (!expectedSecret) return;
+
+ const spireUrl = Deno.env.get("SPIRE_URL") || "https://spire.blog";
+
+ try {
+ const response = await fetch(`${spireUrl}/api/blog/posts/sync-manual`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${expectedSecret}`,
+ },
+ body: JSON.stringify({
+ spirePostId: post.spirePostId,
+ title: post.title,
+ excerpt: post.excerpt,
+ seoTitle: post.seo?.title,
+ seoDescription: post.seo?.description,
+ }),
+ });
+
+ if (!response.ok) {
+ console.error(
+ `[BlogWatcher] Failed to sync manual edit back to Spire: ${response.status}`,
+ );
+ } else {
+ console.info(
+ `[BlogWatcher] Successfully synced manual edit back to Spire for: ${post.slug}`,
+ );
+ }
+ } catch (err) {
+ console.error(
+ "[BlogWatcher] Error sending manual sync update back to Spire:",
+ err,
+ );
+ }
+}
+
/**
* @title Deco Blog
* @description Manage your posts.
@@ -22,6 +147,7 @@ export type AppContext = FnContext;
* @logo https://raw.githubusercontent.com/deco-cx/apps/main/weather/logo.png
*/
export default function App(state: State): App {
+ startBlogWatcher(state);
return { manifest, state };
}
export const preview = () => {
diff --git a/spire/sections/SpirePendingApprovals.tsx b/spire/sections/SpirePendingApprovals.tsx
index c043afa62..76c0077a6 100644
--- a/spire/sections/SpirePendingApprovals.tsx
+++ b/spire/sections/SpirePendingApprovals.tsx
@@ -197,29 +197,89 @@ export default function SpirePendingApprovals({
{/* Actions */}
-
))}
)}
+