@soloworks/smking-next 0.21.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # @soloworks/smking-next
2
2
 
3
+ ## 0.21.1 — 2026-06-12
4
+
5
+ **Webhook replay protection (security review M6).**
6
+
7
+ The webhook receiver now rejects deliveries whose `deliveredAt` falls
8
+ outside a ±5-minute window (401 `stale_delivery`; a missing field counts
9
+ as stale — the SaaS has sent it since v0.11) and dedupes `deliveryId`
10
+ re-sends inside that window (401 `duplicate_delivery`). Dedup is
11
+ best-effort in-memory per serverless instance; the time window is the
12
+ primary defense. The SaaS now stamps every delivery with a unique
13
+ `deliveryId` — payloads from a SaaS predating the field skip dedup and
14
+ keep working. Both fields sit inside the HMAC-signed JSON body, so an
15
+ attacker can't forge or strip them. No customer action needed; `^0.21`
16
+ picks this up automatically.
17
+
18
+ **Mode B block types caught up with the SaaS catalog (types only — no
19
+ runtime change).**
20
+
21
+ The `Block` union had drifted to 3 of the SaaS's 10 block components; it
22
+ now covers all of them — added `search`, `nav-recent-posts`,
23
+ `nav-related-posts`, `nav-category-index`, `image`, `slideshow`, and
24
+ `social-share` (plus the missing `NavSnapshotEntry.href` field). The types
25
+ are no longer a hand-maintained mirror: the monorepo's `@smking/shared`
26
+ package is the single source of truth and `src/cms-blocks.ts` is a
27
+ generated vendored copy (`pnpm sync:block-types`, guarded by a vitest
28
+ sync test). All block prop interfaces are also exported from the package
29
+ root now (`Block`, `BlockComponent`, `SearchProps`, …). Mode A
30
+ (`bodyHtml`) rendering is unaffected.
31
+
3
32
  ## 0.21.0 — 2026-06-09
4
33
 
5
34
  **Per-site CMS theme — `<SmkingRuntime>` now mounts a `theme.css` stylesheet.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soloworks/smking-next",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
4
4
  "description": "AI-native SEO (AEO) for Next.js — auto-inject JSON-LD, FAQ, AI summary, and SEO metadata so AI crawlers (ChatGPT, Perplexity, Google AI) can cite your pages.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/sillyleo/smking/tree/main/packages/smking-next",
@@ -0,0 +1,219 @@
1
+ // GENERATED FILE — do not edit by hand.
2
+ // Vendored copy of packages/shared/src/types/cms-blocks.ts (the SDK ships
3
+ // raw src/ to npm, so it can't import the private workspace package).
4
+ // Regenerate with: pnpm sync:block-types
5
+
6
+ /**
7
+ * CMS block primitives — substrate-pivot v2.
8
+ *
9
+ * Single source of truth for the Mode B (blocks JSON) contract shared by
10
+ * the SaaS dashboard (`apps/web/src/features/cms`) and the customer SDKs.
11
+ * The dashboard editor serializes Plate documents into this shape, the
12
+ * publish pipeline materializes nav snapshots into it, and
13
+ * `/api/v1/public/page` returns it verbatim.
14
+ *
15
+ * ⚠️ `@soloworks/smking-next` publishes raw `src/` to npm and cannot import
16
+ * this private workspace package — it ships a vendored copy at
17
+ * `packages/smking-next/src/cms-blocks.ts` instead. After editing this file
18
+ * run `pnpm sync:block-types` to regenerate it (drift fails the SDK's
19
+ * vitest guard).
20
+ *
21
+ * `article` is the serialize-time HTML container for runs of Plate built-in
22
+ * blocks — paragraphs / headings / lists are collapsed into one HTML body
23
+ * block via Plate's `serializeHtml`. New block primitives land here + in
24
+ * apps/web `block-schemas.ts`.
25
+ *
26
+ * `nav-taxonomy-list` uses a `source` discriminator — category mode is
27
+ * slug-prefix derived, tag mode targets `taxonomies.slug`.
28
+ */
29
+
30
+ export interface HeroProps {
31
+ title: string;
32
+ subtitle?: string;
33
+ image?: { url: string; alt: string };
34
+ cta?: { label: string; href: string };
35
+ /** Header layout — omitted = the original centred hero; "start" = the
36
+ * Apple-newsroom-style left-aligned header; "cover" = title-only banner
37
+ * over a full-bleed background image (Apple services index). Visuals ship
38
+ * via bodyHtml; this mirrors the node attr for SDK Mode B (per-block)
39
+ * consumers. */
40
+ align?: "center" | "start" | "cover";
41
+ }
42
+
43
+ export interface ArticleProps {
44
+ /** Plate `serializeHtml` output — a contiguous run of built-in blocks
45
+ * (paragraphs / headings / lists) collapsed into one HTML body block. */
46
+ html: string;
47
+ }
48
+
49
+ export interface ModuleHeader {
50
+ heading?: string;
51
+ viewAll?: { label: string; href: string };
52
+ }
53
+
54
+ export type NavLayout = "grid" | "list" | "carousel" | "archive";
55
+
56
+ /**
57
+ * Materialized snapshot entry written into nav-* block props at publish
58
+ * time (see apps/web `materialize-blocks.ts` for the resolver). The
59
+ * customer SDK echoes `snapshot` verbatim — it never round-trips back to
60
+ * smking for nav rendering, so if smking is down customer pages still
61
+ * render whatever was last published.
62
+ */
63
+ export interface NavSnapshotEntry {
64
+ slug: string;
65
+ /**
66
+ * Absolute href = the customer's CMS mount prefix + slug (baked at publish
67
+ * from `sites.config.cmsBasePath`). Optional — falls back to `/${slug}`.
68
+ */
69
+ href?: string;
70
+ title: string | null;
71
+ excerpt: string | null;
72
+ featuredImageUrl: string | null;
73
+ /** ISO string. */
74
+ publishedAt: string | null;
75
+ contentType: "article" | "landing" | "listing" | null;
76
+ }
77
+
78
+ /**
79
+ * `search` block — a command-palette over the WHOLE site's published CMS
80
+ * pages. Unlike `nav-taxonomy-list` (which scopes to a category / tag),
81
+ * search bakes EVERY published `cms_page` into `snapshot` at publish time
82
+ * and the `<smking-search>` web component filters the rendered cards
83
+ * client-side. Reuses `NavSnapshotEntry` (the static render only reads
84
+ * `href` / `title` / `excerpt` — no thumbnail, command-palette text feel).
85
+ */
86
+ export interface SearchProps {
87
+ /** Input placeholder. Defaults to "Search…" at render time. */
88
+ placeholder?: string;
89
+ /** Optional heading above the search input. */
90
+ heading?: string;
91
+ /** Publish-time materialized snapshot — every published page on the site. */
92
+ snapshot?: NavSnapshotEntry[];
93
+ }
94
+
95
+ export interface RecentPostsProps extends ModuleHeader {
96
+ /** Author-set number of latest posts to show (1..50). */
97
+ limit: number;
98
+ /** Publish-time materialized snapshot — whole-site newest-first. */
99
+ snapshot?: NavSnapshotEntry[];
100
+ }
101
+
102
+ export interface RelatedPostsProps extends ModuleHeader {
103
+ /** Author-set number of related posts to show (1..50). */
104
+ limit: number;
105
+ /** Publish-time materialized snapshot — same-tag, ranked by overlap. */
106
+ snapshot?: NavSnapshotEntry[];
107
+ }
108
+
109
+ /** One author-selected category in a nav-category-index block. */
110
+ export interface CategoryIndexItem {
111
+ /** Slug prefix identifying the category (e.g. `blog/seo`). */
112
+ path: string;
113
+ /** Display name on the card (slug leaf by default; author-editable). */
114
+ label: string;
115
+ }
116
+
117
+ /**
118
+ * Materialized card for nav-category-index: the category's name, its archive
119
+ * link, and its latest post (null when the category has no published posts).
120
+ */
121
+ export interface CategoryCardSnapshot {
122
+ label: string;
123
+ /** Archive href = mount prefix + category path (baked at publish). */
124
+ href: string;
125
+ latest: NavSnapshotEntry | null;
126
+ }
127
+
128
+ export interface CategoryIndexProps extends ModuleHeader {
129
+ items: CategoryIndexItem[];
130
+ /** Card layout — omitted/"cards" = category cards (archive links);
131
+ * "featured" = Apple-newsroom services-index tiles where each card IS the
132
+ * category's latest article. Visuals ship via bodyHtml; this mirrors the
133
+ * node attr for SDK Mode B (per-block) consumers. */
134
+ layout?: "cards" | "featured";
135
+ snapshot?: CategoryCardSnapshot[];
136
+ }
137
+
138
+ /**
139
+ * `image` block — a single figure (image + optional caption). `widthMode`
140
+ * controls the breakout (content = text column, wide = up to 1024px centred
141
+ * on the container). All fields optional so an unconfigured block is valid;
142
+ * the static render emits nothing until an image is uploaded.
143
+ */
144
+ export interface ImageProps {
145
+ url?: string;
146
+ alt?: string;
147
+ caption?: string;
148
+ widthMode?: "content" | "wide";
149
+ }
150
+
151
+ /** One slide in a `slideshow` block. */
152
+ export interface SlideshowSlideProps {
153
+ url: string;
154
+ alt?: string;
155
+ caption?: string;
156
+ }
157
+
158
+ /**
159
+ * `slideshow` block — an image carousel. `widthMode` controls the breakout
160
+ * (content / wide). `slides` optional so an unconfigured block is valid; the
161
+ * static render emits nothing (and the customer SDK upgrades it into a
162
+ * paginated carousel via the `<smking-slideshow>` web component).
163
+ */
164
+ export interface SlideshowProps {
165
+ slides?: SlideshowSlideProps[];
166
+ widthMode?: "content" | "wide";
167
+ /** Fixed crop ratio for every slide (render default 16:9). */
168
+ aspectRatio?: "16:9" | "4:3";
169
+ }
170
+
171
+ /**
172
+ * `social-share` block — X / Facebook / LinkedIn share links + copy-link. The
173
+ * platforms are fixed; `shareUrl` is the optional no-JS fallback target (the
174
+ * `<smking-share>` web component overrides every link from window.location at
175
+ * runtime, so the live share always points at the real current page).
176
+ */
177
+ export interface SocialShareProps {
178
+ shareUrl?: string;
179
+ /** Heading above the row. Omitted = "Share article" default at render
180
+ * time; empty string = bare icon row (Apple-newsroom header style). */
181
+ label?: string;
182
+ }
183
+
184
+ export interface NavTaxonomyListProps extends ModuleHeader {
185
+ /**
186
+ * Discriminator:
187
+ * - `category-by-path` → `path` is a slug prefix (e.g. `/blog/seo`); SDK
188
+ * queries pages whose slug starts with this prefix.
189
+ * - `tag` → `path` is the taxonomy slug (flat tag namespace).
190
+ */
191
+ source: "category-by-path" | "tag";
192
+ path: string;
193
+ limit: number;
194
+ /** List EVERY published article under the category/tag instead of the
195
+ * latest `limit` (Apple archive page). The resolver still applies a 200
196
+ * safety cap. Omitted (not false) when off — back-compat shape. */
197
+ showAll?: boolean;
198
+ layout: NavLayout;
199
+ /** Publish-time materialized snapshot. */
200
+ snapshot?: NavSnapshotEntry[];
201
+ }
202
+
203
+ export type Block =
204
+ | { component: "hero"; id: string; props: HeroProps }
205
+ | { component: "article"; id: string; props: ArticleProps }
206
+ | {
207
+ component: "nav-taxonomy-list";
208
+ id: string;
209
+ props: NavTaxonomyListProps;
210
+ }
211
+ | { component: "search"; id: string; props: SearchProps }
212
+ | { component: "nav-recent-posts"; id: string; props: RecentPostsProps }
213
+ | { component: "nav-related-posts"; id: string; props: RelatedPostsProps }
214
+ | { component: "nav-category-index"; id: string; props: CategoryIndexProps }
215
+ | { component: "image"; id: string; props: ImageProps }
216
+ | { component: "slideshow"; id: string; props: SlideshowProps }
217
+ | { component: "social-share"; id: string; props: SocialShareProps };
218
+
219
+ export type BlockComponent = Block["component"];
package/src/index.ts CHANGED
@@ -6,6 +6,12 @@ export { getCmsPage } from "./lib/cms-client";
6
6
  export type {
7
7
  AeoResponse,
8
8
  AeoStatus,
9
+ ArticleProps,
10
+ Block,
11
+ BlockComponent,
12
+ CategoryCardSnapshot,
13
+ CategoryIndexItem,
14
+ CategoryIndexProps,
9
15
  ChatLinks,
10
16
  CmsPage,
11
17
  CmsParams,
@@ -13,5 +19,17 @@ export type {
13
19
  CmsStatus,
14
20
  DiscoverParams,
15
21
  FaqItem,
22
+ HeroProps,
23
+ ImageProps,
24
+ ModuleHeader,
25
+ NavLayout,
26
+ NavSnapshotEntry,
27
+ NavTaxonomyListProps,
28
+ RecentPostsProps,
29
+ RelatedPostsProps,
30
+ SearchProps,
16
31
  SeoMeta,
32
+ SlideshowProps,
33
+ SlideshowSlideProps,
34
+ SocialShareProps,
17
35
  } from "./types";
@@ -7,6 +7,35 @@ interface WebhookPayload {
7
7
  paths?: string[];
8
8
  slugs?: string[];
9
9
  deliveredAt?: string;
10
+ deliveryId?: string;
11
+ }
12
+
13
+ // Replay protection (v0.21.1+). The deliveredAt window is the primary
14
+ // defense — ±5min absorbs clock skew between SaaS and customer while
15
+ // capping how long an intercepted delivery stays replayable. deliveryId
16
+ // dedup is best-effort on top: serverless instances each hold their own
17
+ // Map, so a replay landing on a different instance only has the window
18
+ // to beat. SaaS payloads predating deliveryId pass dedup untouched.
19
+ const REPLAY_WINDOW_MS = 5 * 60 * 1000;
20
+ const SEEN_DELIVERIES_MAX = 1000;
21
+ const seenDeliveries = new Map<string, number>(); // deliveryId → expiry (ms)
22
+
23
+ function isDuplicateDelivery(
24
+ deliveryId: string | undefined,
25
+ now: number,
26
+ ): boolean {
27
+ for (const [id, expiry] of seenDeliveries) {
28
+ if (expiry <= now) seenDeliveries.delete(id);
29
+ }
30
+ if (typeof deliveryId !== "string" || deliveryId.length === 0) return false;
31
+ if (seenDeliveries.has(deliveryId)) return true;
32
+ if (seenDeliveries.size >= SEEN_DELIVERIES_MAX) {
33
+ // Map iterates in insertion order — evict the oldest entry.
34
+ const oldest = seenDeliveries.keys().next().value;
35
+ if (oldest !== undefined) seenDeliveries.delete(oldest);
36
+ }
37
+ seenDeliveries.set(deliveryId, now + REPLAY_WINDOW_MS);
38
+ return false;
10
39
  }
11
40
 
12
41
  /**
@@ -41,6 +70,13 @@ interface WebhookPayload {
41
70
  * Auth: HMAC-SHA256 only. Bearer dropped — single auth model means one
42
71
  * env var, one shim, fewer customer mis-config paths (the disambiguation
43
72
  * problem the v0.10 dual-handler shipped with).
73
+ *
74
+ * Replay protection (v0.21.1+): a signed delivery is only accepted while
75
+ * `deliveredAt` sits inside a ±5min window (401 `stale_delivery`
76
+ * otherwise — missing field counts as stale), and a `deliveryId` seen
77
+ * before within that window is rejected (401 `duplicate_delivery`).
78
+ * Payloads from a SaaS predating `deliveryId` skip dedup — the window
79
+ * remains the primary defense.
44
80
  */
45
81
  export async function POST(request: Request): Promise<Response> {
46
82
  const secret = process.env.SMKING_WEBHOOK_SECRET;
@@ -66,6 +102,23 @@ export async function POST(request: Request): Promise<Response> {
66
102
  return Response.json({ error: "invalid_payload" }, { status: 400 });
67
103
  }
68
104
 
105
+ // Replay protection — deliveredAt must sit inside a ±5min window
106
+ // (missing/unparseable counts as stale; SaaS has sent it since v0.11),
107
+ // then deliveryId dedup rejects re-sends of a delivery we already saw.
108
+ const now = Date.now();
109
+ const deliveredAtMs = payload.deliveredAt
110
+ ? Date.parse(payload.deliveredAt)
111
+ : Number.NaN;
112
+ if (
113
+ Number.isNaN(deliveredAtMs) ||
114
+ Math.abs(now - deliveredAtMs) > REPLAY_WINDOW_MS
115
+ ) {
116
+ return Response.json({ error: "stale_delivery" }, { status: 401 });
117
+ }
118
+ if (isDuplicateDelivery(payload.deliveryId, now)) {
119
+ return Response.json({ error: "duplicate_delivery" }, { status: 401 });
120
+ }
121
+
69
122
  const kind = payload.kind;
70
123
  if (typeof kind !== "string" || kind.length === 0) {
71
124
  // Forward-compat: SaaS may emit kinds we haven't taught the SDK
package/src/types.ts CHANGED
@@ -64,58 +64,34 @@ export type CmsContentType = "article" | "landing" | "listing";
64
64
  // `<article class="prose">` around `<SmkingCms>` themselves.
65
65
 
66
66
  /**
67
- * v2 substrate block primitives. Mirrors `apps/web/src/features/cms/types.ts`
68
- * on the SaaS side (kept in sync manually — when the v1 block catalog
69
- * grows past 5 primitives, a shared workspace package replaces this
70
- * duplication per `docs/superpowers/specs/2026-05-15-cms-dashboard-editor-design.md`
71
- * section §11 + ai-cms-v2 §"擴充性").
67
+ * v2 substrate block primitives. Single source of truth lives in the
68
+ * monorepo's `packages/shared/src/types/cms-blocks.ts`; this package ships
69
+ * a vendored copy (`./cms-blocks`, regenerated via `pnpm sync:block-types`)
70
+ * because raw `src/` is published to npm and can't depend on the private
71
+ * workspace package. A vitest guard fails when the copy drifts.
72
72
  */
73
- export interface HeroProps {
74
- title: string;
75
- subtitle?: string;
76
- image?: { url: string; alt: string };
77
- cta?: { label: string; href: string };
78
- }
79
- export interface ArticleProps {
80
- html: string;
81
- }
82
- export interface ModuleHeader {
83
- heading?: string;
84
- viewAll?: { label: string; href: string };
85
- }
86
- export type NavLayout = "grid" | "list" | "carousel";
87
-
88
- /**
89
- * Materialized snapshot entry — published pages on the smking SaaS
90
- * pre-resolve their nav-* block data at publish time and bake it into
91
- * `block.props.snapshot`. Customer SDK echoes this verbatim, never
92
- * round-trips back to smking for nav rendering. If smking is down,
93
- * customer pages still render whatever was last published.
94
- */
95
- export interface NavSnapshotEntry {
96
- slug: string;
97
- title: string | null;
98
- excerpt: string | null;
99
- featuredImageUrl: string | null;
100
- /** ISO string. */
101
- publishedAt: string | null;
102
- contentType: "article" | "landing" | "listing" | null;
103
- }
104
-
105
- export interface NavTaxonomyListProps extends ModuleHeader {
106
- /** category-by-path: slug prefix; tag: taxonomies.slug */
107
- source: "category-by-path" | "tag";
108
- path: string;
109
- limit: number;
110
- layout: NavLayout;
111
- /** Publish-time materialized — never fetched live. */
112
- snapshot?: NavSnapshotEntry[];
113
- }
114
-
115
- export type Block =
116
- | { component: "hero"; id: string; props: HeroProps }
117
- | { component: "article"; id: string; props: ArticleProps }
118
- | { component: "nav-taxonomy-list"; id: string; props: NavTaxonomyListProps };
73
+ import type { Block } from "./cms-blocks";
74
+
75
+ export type {
76
+ ArticleProps,
77
+ Block,
78
+ BlockComponent,
79
+ CategoryCardSnapshot,
80
+ CategoryIndexItem,
81
+ CategoryIndexProps,
82
+ HeroProps,
83
+ ImageProps,
84
+ ModuleHeader,
85
+ NavLayout,
86
+ NavSnapshotEntry,
87
+ NavTaxonomyListProps,
88
+ RecentPostsProps,
89
+ RelatedPostsProps,
90
+ SearchProps,
91
+ SlideshowProps,
92
+ SlideshowSlideProps,
93
+ SocialShareProps,
94
+ } from "./cms-blocks";
119
95
 
120
96
  /**
121
97
  * A single published CMS page returned by the smking public API