@soloworks/smking-next 0.21.0 → 0.21.2

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,42 @@
1
1
  # @soloworks/smking-next
2
2
 
3
+ ## 0.21.2 — 2026-07-02
4
+
5
+ **CMS taxonomy page types caught up with the SaaS catalog (types only — no runtime change).**
6
+
7
+ The CMS page model now includes the `taxonomy` content type used by protected
8
+ category and tag index pages. This keeps the published SDK type surface aligned
9
+ with the SaaS block contract after the taxonomy-page normalization work.
10
+
11
+ ## 0.21.1 — 2026-06-12
12
+
13
+ **Webhook replay protection (security review M6).**
14
+
15
+ The webhook receiver now rejects deliveries whose `deliveredAt` falls
16
+ outside a ±5-minute window (401 `stale_delivery`; a missing field counts
17
+ as stale — the SaaS has sent it since v0.11) and dedupes `deliveryId`
18
+ re-sends inside that window (401 `duplicate_delivery`). Dedup is
19
+ best-effort in-memory per serverless instance; the time window is the
20
+ primary defense. The SaaS now stamps every delivery with a unique
21
+ `deliveryId` — payloads from a SaaS predating the field skip dedup and
22
+ keep working. Both fields sit inside the HMAC-signed JSON body, so an
23
+ attacker can't forge or strip them. No customer action needed; `^0.21`
24
+ picks this up automatically.
25
+
26
+ **Mode B block types caught up with the SaaS catalog (types only — no
27
+ runtime change).**
28
+
29
+ The `Block` union had drifted to 3 of the SaaS's 10 block components; it
30
+ now covers all of them — added `search`, `nav-recent-posts`,
31
+ `nav-related-posts`, `nav-category-index`, `image`, `slideshow`, and
32
+ `social-share` (plus the missing `NavSnapshotEntry.href` field). The types
33
+ are no longer a hand-maintained mirror: the monorepo's `@smking/shared`
34
+ package is the single source of truth and `src/cms-blocks.ts` is a
35
+ generated vendored copy (`pnpm sync:block-types`, guarded by a vitest
36
+ sync test). All block prop interfaces are also exported from the package
37
+ root now (`Block`, `BlockComponent`, `SearchProps`, …). Mode A
38
+ (`bodyHtml`) rendering is unaffected.
39
+
3
40
  ## 0.21.0 — 2026-06-09
4
41
 
5
42
  **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.2",
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,470 @@
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
+ /** Generic visible meta lines. Legacy drafts may still also carry subtitle. */
33
+ line1?: string;
34
+ line2?: string;
35
+ /** When true, render the backend page publish date. */
36
+ showPublishedAt?: boolean;
37
+ /** ISO or display date injected from page metadata at publish time. */
38
+ publishedAt?: string;
39
+ /** Legacy single-line fallback for SDK Mode B consumers. */
40
+ subtitle?: string;
41
+ image?: { url: string; alt: string };
42
+ cta?: { label: string; href: string };
43
+ /** Header layout — omitted = the original centred hero; "start" = the
44
+ * Apple-newsroom-style left-aligned header; "cover" = title-only banner over
45
+ * a full-bleed background image with a dark scrim (Apple services index);
46
+ * "cover-plain" = the same banner WITHOUT the scrim (image shown untouched).
47
+ * Visuals ship via bodyHtml; this mirrors the node attr for SDK Mode B
48
+ * (per-block) consumers. */
49
+ align?: "center" | "start" | "cover" | "cover-plain";
50
+ /** Crop ratio for cover heroes and the optional hero image.
51
+ * Omitted = auto, preserving the original image ratio. */
52
+ aspectRatio?: "auto" | "16:9" | "4:3";
53
+ }
54
+
55
+ export interface ArticleProps {
56
+ /** Plate `serializeHtml` output — a contiguous run of built-in blocks
57
+ * (paragraphs / headings / lists) collapsed into one HTML body block. */
58
+ html: string;
59
+ }
60
+
61
+ export interface ModuleHeader {
62
+ heading?: string;
63
+ viewAll?: { label: string; href: string };
64
+ }
65
+
66
+ export type NavLayout = "grid" | "list" | "carousel" | "archive";
67
+
68
+ /**
69
+ * Materialized snapshot entry written into nav-* block props at publish
70
+ * time (see apps/web `materialize-blocks.ts` for the resolver). The
71
+ * customer SDK echoes `snapshot` verbatim — it never round-trips back to
72
+ * smking for nav rendering, so if smking is down customer pages still
73
+ * render whatever was last published.
74
+ */
75
+ export interface NavSnapshotEntry {
76
+ slug: string;
77
+ /**
78
+ * Absolute href = the customer's CMS mount prefix + slug (baked at publish
79
+ * from `sites.config.cmsBasePath`). Optional — falls back to `/${slug}`.
80
+ */
81
+ href?: string;
82
+ title: string | null;
83
+ excerpt: string | null;
84
+ featuredImageUrl: string | null;
85
+ /** ISO string. */
86
+ publishedAt: string | null;
87
+ /** ISO string. Present when the article is manually pinned in CMS feeds. */
88
+ pinnedAt?: string | null;
89
+ contentType: "article" | "landing" | "listing" | "taxonomy" | null;
90
+ }
91
+
92
+ export type SearchQuickLinkSource = "category-by-path" | "tag" | "url";
93
+
94
+ /**
95
+ * Optional links shown beside the search trigger on desktop and inside the
96
+ * search panel on small screens. Category/tag links keep their semantic source
97
+ * + path so publish-time materialization can bake the customer's CMS mount
98
+ * prefix into `href`; URL links carry the author's literal `href`.
99
+ */
100
+ export interface SearchQuickLink {
101
+ label: string;
102
+ source: SearchQuickLinkSource;
103
+ /** Category slug path or flat tag slug. Not used for arbitrary URLs. */
104
+ path?: string;
105
+ /** Materialized href for category/tag links, or the author-entered URL. */
106
+ href?: string;
107
+ }
108
+
109
+ /**
110
+ * `search` block — a command-palette over the WHOLE site's published CMS
111
+ * pages. Unlike `nav-taxonomy-list` (which scopes to a category / tag),
112
+ * search bakes EVERY published `cms_page` into `snapshot` at publish time
113
+ * and the `<smking-search>` web component filters the rendered cards
114
+ * client-side. Reuses `NavSnapshotEntry` (the static render only reads
115
+ * `href` / `title` / `excerpt` — no thumbnail, command-palette text feel).
116
+ */
117
+ export interface SearchProps {
118
+ /** Input placeholder. Defaults to "Search…" at render time. */
119
+ placeholder?: string;
120
+ /** Optional heading above the search input. */
121
+ heading?: string;
122
+ /** Content column vs wide breakout (default wide). Mirrors the media
123
+ * blocks (image/embed). */
124
+ widthMode?: "content" | "wide";
125
+ /** Optional quick links adjacent to search. */
126
+ links?: SearchQuickLink[];
127
+ /** Publish-time materialized snapshot — every published page on the site. */
128
+ snapshot?: NavSnapshotEntry[];
129
+ }
130
+
131
+ export interface RecentPostsProps extends ModuleHeader {
132
+ /** Author-set number of latest posts to show (1..50). */
133
+ limit: number;
134
+ /** Content column vs wide breakout (default content). Mirrors the media
135
+ * blocks. */
136
+ widthMode?: "content" | "wide";
137
+ /** Publish-time materialized snapshot — whole-site newest-first. */
138
+ snapshot?: NavSnapshotEntry[];
139
+ }
140
+
141
+ /** One author-picked source for nav-related-posts. Same shape as
142
+ * `CategoryIndexItem` (a category path OR a tag), reused so the source picker
143
+ * UI stays identical across blocks. */
144
+ export interface RelatedPostsSource {
145
+ /** Slug prefix (category-by-path) or taxonomy slug (tag), per `source`. */
146
+ path: string;
147
+ /** Display name (only used by the editor chip; resolver ignores it). */
148
+ label: string;
149
+ /** Omitted = `category-by-path` (back-compat, mirrors CategoryIndexItem). */
150
+ source?: "category-by-path" | "tag";
151
+ }
152
+
153
+ export interface RelatedPostsProps extends ModuleHeader {
154
+ /** Authoring mode before materialization. Published blocks carry resolved
155
+ * `heading`; SDK consumers can ignore this field. */
156
+ headingMode?: "global" | "custom";
157
+ /** Author-set number of related posts to show (1..50). */
158
+ limit: number;
159
+ /** Content column vs wide breakout (default content). Mirrors the media
160
+ * blocks. */
161
+ widthMode?: "content" | "wide";
162
+ /** Author-picked sources. Empty/omitted → auto-fill from the CURRENT page's
163
+ * category, derived from its slug at publish time. When set, related posts
164
+ * are the latest across ALL picked sources (OR), newest-first, excluding the
165
+ * current page. */
166
+ sources?: RelatedPostsSource[];
167
+ /** Publish-time materialized snapshot. */
168
+ snapshot?: NavSnapshotEntry[];
169
+ }
170
+
171
+ /** One tag attached to the current article, materialized at publish time. */
172
+ export interface ArticleTagSnapshot {
173
+ slug: string;
174
+ name: string;
175
+ /** Virtual tag archive href, e.g. `/blog/tag/tofu-life`. */
176
+ href: string;
177
+ }
178
+
179
+ /**
180
+ * `article-tags` block — renders the current article's CMS tags as chips.
181
+ * The tags are page metadata, not authored inside the block, so `snapshot`
182
+ * is filled at publish time from `content_taxonomies`.
183
+ */
184
+ export interface ArticleTagsProps {
185
+ /** Optional section heading. Omitted / empty means no heading. */
186
+ heading?: string;
187
+ /** Publish-time materialized current-article tags. */
188
+ snapshot?: ArticleTagSnapshot[];
189
+ }
190
+
191
+ /** One author-selected category OR tag in a nav-category-index block. */
192
+ export interface CategoryIndexItem {
193
+ /** Slug prefix (category-by-path) or taxonomy slug (tag), per `source`.
194
+ * e.g. category `blog/seo`, tag `announcements`. */
195
+ path: string;
196
+ /** Display name on the card (slug leaf / tag name by default; author-editable). */
197
+ label: string;
198
+ /** Source discriminator (mirrors nav-taxonomy-list). Omitted =
199
+ * `category-by-path` — back-compat: pre-tag nodes carry no source. A `tag`
200
+ * card shows that tag's latest post + links to its archive. */
201
+ source?: "category-by-path" | "tag";
202
+ }
203
+
204
+ /**
205
+ * Materialized card for nav-category-index: the category's name, its archive
206
+ * link, and its latest post (null when the category has no published posts).
207
+ */
208
+ export interface CategoryCardSnapshot {
209
+ label: string;
210
+ /** Archive href = mount prefix + category path (baked at publish). */
211
+ href: string;
212
+ latest: NavSnapshotEntry | null;
213
+ }
214
+
215
+ export interface CategoryIndexProps extends ModuleHeader {
216
+ /** `auto` = listing categories directly under `autoBasePath` at publish
217
+ * time; `manual` = exactly the picked items below. Omitted = back-compat:
218
+ * items present means manual, otherwise auto. */
219
+ mode?: "auto" | "manual";
220
+ /** Auto mode source. Omitted = category hierarchy, for back-compat. */
221
+ autoSource?: "category-by-path" | "tag";
222
+ /** Auto mode source hierarchy. Empty string means root-level listing
223
+ * categories; any other value is a listing slug whose direct child listing
224
+ * pages should be shown. Omitted falls back to root for new materialization. */
225
+ autoBasePath?: string;
226
+ items: CategoryIndexItem[];
227
+ /** Card layout — omitted/"cards" = category cards (archive links);
228
+ * "featured" = Apple-newsroom services-index tiles where each card IS the
229
+ * category's latest article. Visuals ship via bodyHtml; this mirrors the
230
+ * node attr for SDK Mode B (per-block) consumers. */
231
+ layout?: "cards" | "featured";
232
+ /** Content column vs wide breakout. Omitted = layout-derived default
233
+ * (cards → content, featured → wide); set explicitly to override. */
234
+ widthMode?: "content" | "wide";
235
+ snapshot?: CategoryCardSnapshot[];
236
+ }
237
+
238
+ export type MediaAspectRatio = "auto" | "16:9" | "4:3" | "square";
239
+
240
+ /**
241
+ * `image` block — a single figure (image + optional caption). `widthMode`
242
+ * controls the breakout (content = text column, wide = up to 1024px centred
243
+ * on the container). All fields optional so an unconfigured block is valid;
244
+ * the static render emits nothing until an image is uploaded.
245
+ */
246
+ export interface ImageProps {
247
+ url?: string;
248
+ alt?: string;
249
+ caption?: string;
250
+ widthMode?: "content" | "wide";
251
+ /** Plain image vs soft neutral tonal surface. */
252
+ styleVariant?: "plain" | "tonal";
253
+ /** Omitted / auto preserves the original image ratio. Fixed values crop. */
254
+ aspectRatio?: MediaAspectRatio;
255
+ }
256
+
257
+ /** One slide in a `slideshow` block. */
258
+ export interface SlideshowSlideProps {
259
+ url: string;
260
+ alt?: string;
261
+ caption?: string;
262
+ }
263
+
264
+ /**
265
+ * `slideshow` block — an image carousel. `widthMode` controls the breakout
266
+ * (content / wide). `slides` optional so an unconfigured block is valid; the
267
+ * static render emits nothing (and the customer SDK upgrades it into a
268
+ * paginated carousel via the `<smking-slideshow>` web component).
269
+ */
270
+ export interface SlideshowProps {
271
+ slides?: SlideshowSlideProps[];
272
+ widthMode?: "content" | "wide";
273
+ /** Fixed crop ratio for every slide (render default 16:9). */
274
+ aspectRatio?: "16:9" | "4:3";
275
+ }
276
+
277
+ /** One image in a `collage` block. Width/height are optional natural
278
+ * dimensions saved at authoring time so the auto layout can keep row
279
+ * proportions stable without loading images first. */
280
+ export interface CollageImageProps {
281
+ url: string;
282
+ alt?: string;
283
+ width?: number;
284
+ height?: number;
285
+ }
286
+
287
+ /**
288
+ * `collage` block — a 1–6 image composition. `auto` uses natural image
289
+ * ratios and the wide breakout; `row-grid` keeps each row at one height while
290
+ * allowing different rows to have different proportions. Content width forces
291
+ * `row-grid` at render time.
292
+ */
293
+ export interface CollageProps {
294
+ images?: CollageImageProps[];
295
+ widthMode?: "content" | "wide";
296
+ mode?: "auto" | "row-grid";
297
+ }
298
+
299
+ export type SocialSharePlatform =
300
+ | "threads"
301
+ | "instagram"
302
+ | "facebook"
303
+ | "line"
304
+ | "copy";
305
+
306
+ /**
307
+ * `social-share` block — Threads / Instagram / Facebook / LINE share links +
308
+ * copy-link.
309
+ * Platform visibility is site-wide; `shareUrl` is the optional no-JS fallback
310
+ * target (the `<smking-share>` web component overrides every link from
311
+ * window.location at runtime, so the live share always points at the real
312
+ * current page).
313
+ */
314
+ export interface SocialShareProps {
315
+ shareUrl?: string;
316
+ /** Global mode reads the site-wide share title. Custom mode reads `label`.
317
+ * Omitted = global; empty resolved title = bare icon row. */
318
+ headingMode?: "global" | "custom";
319
+ label?: string;
320
+ /** Site-wide platform visibility snapshot baked into published output. */
321
+ platforms?: SocialSharePlatform[];
322
+ }
323
+
324
+ export interface NavTaxonomyListProps extends ModuleHeader {
325
+ /**
326
+ * Discriminator:
327
+ * - `category-by-path` → `path` is a slug prefix (e.g. `/blog/seo`); SDK
328
+ * queries pages whose slug starts with this prefix.
329
+ * - `tag` → `path` is the taxonomy slug (flat tag namespace).
330
+ */
331
+ source: "category-by-path" | "tag";
332
+ path: string;
333
+ limit: number;
334
+ /** List EVERY published article under the category/tag instead of the
335
+ * latest `limit` (Apple archive page). The resolver still applies a 200
336
+ * safety cap. Omitted (not false) when off — back-compat shape. */
337
+ showAll?: boolean;
338
+ layout: NavLayout;
339
+ /** Content column vs wide breakout (default content). Mirrors the media
340
+ * blocks. */
341
+ widthMode?: "content" | "wide";
342
+ /** Publish-time materialized snapshot. */
343
+ snapshot?: NavSnapshotEntry[];
344
+ }
345
+
346
+ /** One button in a `button-group` block. */
347
+ export interface ButtonItemProps {
348
+ label: string;
349
+ href: string;
350
+ /** Apple-style looks: `filled` = primary capsule, `tinted` = soft primary
351
+ * wash, `outline` = bordered. Omitted = filled. */
352
+ variant?: "filled" | "tinted" | "outline";
353
+ }
354
+
355
+ /**
356
+ * `button-group` block — a row of Apple-style capsule CTAs. Buttons wrap on
357
+ * narrow screens; `align` positions the row. Empty `buttons` is valid (an
358
+ * unconfigured block renders nothing on the customer side).
359
+ */
360
+ export interface ButtonGroupProps {
361
+ buttons?: ButtonItemProps[];
362
+ align?: "start" | "center";
363
+ }
364
+
365
+ /**
366
+ * `embed` block — a privacy-friendly YouTube embed (youtube-nocookie
367
+ * iframe). `url` accepts any YouTube form (watch / youtu.be / shorts /
368
+ * embed); the video id is resolved at render time. No url / unrecognised
369
+ * url = renders nothing on the customer side. `widthMode` matches the other
370
+ * media blocks (content column vs wide breakout).
371
+ */
372
+ export interface EmbedProps {
373
+ url?: string;
374
+ caption?: string;
375
+ widthMode?: "content" | "wide";
376
+ }
377
+
378
+ /**
379
+ * `media-text` block — Apple-style feature row: image on one side, RICH
380
+ * EDITABLE text on the other. Unlike the attr-panel blocks, the text is
381
+ * real Plate content (the node's children — headings/paragraphs edited
382
+ * inline); `html` carries its serialized form for Mode B consumers, while
383
+ * Mode A ships the full markup inside bodyHtml as usual.
384
+ */
385
+ export interface MediaTextProps {
386
+ imageUrl?: string;
387
+ imageAlt?: string;
388
+ /** Which side the IMAGE sits on (md+ screens; stacks on mobile). */
389
+ side?: "left" | "right";
390
+ /** Content column vs wide breakout (default wide). Mirrors the media
391
+ * blocks. */
392
+ widthMode?: "content" | "wide";
393
+ /** Plain row vs soft neutral tonal surface. */
394
+ styleVariant?: "plain" | "tonal";
395
+ /** Omitted / auto preserves the original image ratio. Fixed values crop. */
396
+ aspectRatio?: MediaAspectRatio;
397
+ /** Serialized text-column HTML (class-stripped, same treatment as
398
+ * `article`). */
399
+ html: string;
400
+ }
401
+
402
+ /**
403
+ * One slot in a `latest-news` block — sourced independently from EITHER a
404
+ * specific article (by slug) or a category (shows that category's latest
405
+ * post). The discriminator mirrors `nav-taxonomy-list`'s source pattern.
406
+ */
407
+ export type LatestNewsSlot =
408
+ | { kind: "article"; slug: string }
409
+ | {
410
+ kind: "category";
411
+ path: string;
412
+ label: string;
413
+ /** Source discriminator (mirrors nav-taxonomy-list). Omitted =
414
+ * `category-by-path` — back-compat. A `tag` slot shows the latest post
415
+ * carrying that tag. */
416
+ source?: "category-by-path" | "tag";
417
+ };
418
+
419
+ /**
420
+ * One materialized `latest-news` card: the resolved article entry (the
421
+ * article itself for an `article` slot, or the category's latest post for a
422
+ * `category` slot) plus an optional eyebrow (the category label for category
423
+ * slots; null for article slots). A null `entry` = the slot's source
424
+ * resolved to nothing and is skipped at render.
425
+ */
426
+ export interface LatestNewsCardSnapshot {
427
+ eyebrow: string | null;
428
+ entry: NavSnapshotEntry | null;
429
+ }
430
+
431
+ /**
432
+ * `latest-news` block — the Apple-newsroom "Latest News" magazine grid.
433
+ * 3–6 author-curated slots; the first is the large feature card, the rest
434
+ * scale down to medium / small tiles (6 = 1 feature + 2 medium + 3 small,
435
+ * down to 3 = 1 feature + 2 medium). Materialized at publish like the nav-*
436
+ * feeds; reuses `NavSnapshotEntry` for each resolved card.
437
+ */
438
+ export interface LatestNewsProps extends ModuleHeader {
439
+ /** Author-curated slots, 3–6, in display order (first = feature card). */
440
+ slots: LatestNewsSlot[];
441
+ /** Content column vs wide breakout (default wide). Mirrors the media
442
+ * blocks. */
443
+ widthMode?: "content" | "wide";
444
+ /** Publish-time materialized snapshot — one card per slot, same order. */
445
+ snapshot?: LatestNewsCardSnapshot[];
446
+ }
447
+
448
+ export type Block =
449
+ | { component: "hero"; id: string; props: HeroProps }
450
+ | { component: "article"; id: string; props: ArticleProps }
451
+ | {
452
+ component: "nav-taxonomy-list";
453
+ id: string;
454
+ props: NavTaxonomyListProps;
455
+ }
456
+ | { component: "search"; id: string; props: SearchProps }
457
+ | { component: "nav-recent-posts"; id: string; props: RecentPostsProps }
458
+ | { component: "nav-related-posts"; id: string; props: RelatedPostsProps }
459
+ | { component: "article-tags"; id: string; props: ArticleTagsProps }
460
+ | { component: "nav-category-index"; id: string; props: CategoryIndexProps }
461
+ | { component: "image"; id: string; props: ImageProps }
462
+ | { component: "slideshow"; id: string; props: SlideshowProps }
463
+ | { component: "collage"; id: string; props: CollageProps }
464
+ | { component: "social-share"; id: string; props: SocialShareProps }
465
+ | { component: "button-group"; id: string; props: ButtonGroupProps }
466
+ | { component: "embed"; id: string; props: EmbedProps }
467
+ | { component: "media-text"; id: string; props: MediaTextProps }
468
+ | { component: "latest-news"; id: string; props: LatestNewsProps };
469
+
470
+ 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,20 @@ 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
+ SearchQuickLink,
31
+ SearchQuickLinkSource,
32
+ SearchProps,
16
33
  SeoMeta,
34
+ SlideshowProps,
35
+ SlideshowSlideProps,
36
+ SocialSharePlatform,
37
+ SocialShareProps,
17
38
  } 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
@@ -56,7 +56,7 @@ export interface AeoResponse {
56
56
 
57
57
  export type CmsStatus = "ready" | "preview" | "pending" | "not_found";
58
58
 
59
- export type CmsContentType = "article" | "landing" | "listing";
59
+ export type CmsContentType = "article" | "landing" | "listing" | "taxonomy";
60
60
 
61
61
  // `CmsThemeMode` removed in v0.15.0 — Mode B (`tailwind-prose`) dropped
62
62
  // in favour of the canonical Plate-serialized HTML render (Mode A only).
@@ -64,58 +64,37 @@ 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
+ SearchQuickLink,
91
+ SearchQuickLinkSource,
92
+ SearchProps,
93
+ SlideshowProps,
94
+ SlideshowSlideProps,
95
+ SocialSharePlatform,
96
+ SocialShareProps,
97
+ } from "./cms-blocks";
119
98
 
120
99
  /**
121
100
  * A single published CMS page returned by the smking public API