hazo_blog 1.6.0 → 2.16.0

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.
Files changed (167) hide show
  1. package/CHANGE_LOG.md +946 -0
  2. package/README.md +1 -0
  3. package/db_setup_postgres.sql +727 -39
  4. package/db_setup_sqlite.sql +425 -38
  5. package/dist/index.d.ts +24 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +24 -0
  8. package/dist/jobs/maintenance-jobs.d.ts +83 -0
  9. package/dist/jobs/maintenance-jobs.d.ts.map +1 -0
  10. package/dist/jobs/maintenance-jobs.js +123 -0
  11. package/dist/lib/domain-dns.d.ts +15 -0
  12. package/dist/lib/domain-dns.d.ts.map +1 -0
  13. package/dist/lib/domain-dns.js +27 -0
  14. package/dist/lib/domain-token.d.ts +23 -0
  15. package/dist/lib/domain-token.d.ts.map +1 -0
  16. package/dist/lib/domain-token.js +42 -0
  17. package/dist/lib/hook-bus.d.ts +165 -0
  18. package/dist/lib/hook-bus.d.ts.map +1 -0
  19. package/dist/lib/hook-bus.js +90 -0
  20. package/dist/lib/index.d.ts +8 -0
  21. package/dist/lib/index.d.ts.map +1 -1
  22. package/dist/lib/index.js +8 -0
  23. package/dist/lib/revisions.d.ts +10 -0
  24. package/dist/lib/revisions.d.ts.map +1 -0
  25. package/dist/lib/revisions.js +18 -0
  26. package/dist/lib/safe-url.d.ts +8 -0
  27. package/dist/lib/safe-url.d.ts.map +1 -0
  28. package/dist/lib/safe-url.js +32 -0
  29. package/dist/lib/sections.d.ts +59 -0
  30. package/dist/lib/sections.d.ts.map +1 -0
  31. package/dist/lib/sections.js +303 -0
  32. package/dist/lib/site-identity.d.ts +45 -0
  33. package/dist/lib/site-identity.d.ts.map +1 -0
  34. package/dist/lib/site-identity.js +116 -0
  35. package/dist/lib/status.d.ts +25 -0
  36. package/dist/lib/status.d.ts.map +1 -0
  37. package/dist/lib/status.js +58 -0
  38. package/dist/lib/theme-schema.d.ts +51 -0
  39. package/dist/lib/theme-schema.d.ts.map +1 -0
  40. package/dist/lib/theme-schema.js +112 -0
  41. package/dist/lib/theme-validate.d.ts +48 -0
  42. package/dist/lib/theme-validate.d.ts.map +1 -0
  43. package/dist/lib/theme-validate.js +149 -0
  44. package/dist/next/byline-routes.d.ts +67 -0
  45. package/dist/next/byline-routes.d.ts.map +1 -0
  46. package/dist/next/byline-routes.js +235 -0
  47. package/dist/next/domain-routes.d.ts +9 -0
  48. package/dist/next/domain-routes.d.ts.map +1 -0
  49. package/dist/next/domain-routes.js +39 -0
  50. package/dist/next/post-routes.d.ts +100 -0
  51. package/dist/next/post-routes.d.ts.map +1 -0
  52. package/dist/next/post-routes.js +291 -0
  53. package/dist/render/engine.d.ts +41 -0
  54. package/dist/render/engine.d.ts.map +1 -0
  55. package/dist/render/engine.js +91 -0
  56. package/dist/render/fallback-theme/templates/404.liquid +1 -0
  57. package/dist/render/fallback-theme/templates/default.liquid +1 -0
  58. package/dist/render/fallback-theme.d.ts +3 -0
  59. package/dist/render/fallback-theme.d.ts.map +1 -0
  60. package/dist/render/fallback-theme.js +34 -0
  61. package/dist/render/html.d.ts +10 -0
  62. package/dist/render/html.d.ts.map +1 -0
  63. package/dist/render/html.js +29 -0
  64. package/dist/render/index.d.ts +13 -0
  65. package/dist/render/index.d.ts.map +1 -0
  66. package/dist/render/index.js +23 -0
  67. package/dist/render/resolve.d.ts +23 -0
  68. package/dist/render/resolve.d.ts.map +1 -0
  69. package/dist/render/resolve.js +32 -0
  70. package/dist/render/tags/faq.d.ts +9 -0
  71. package/dist/render/tags/faq.d.ts.map +1 -0
  72. package/dist/render/tags/faq.js +31 -0
  73. package/dist/render/tags/image.d.ts +12 -0
  74. package/dist/render/tags/image.d.ts.map +1 -0
  75. package/dist/render/tags/image.js +56 -0
  76. package/dist/render/tags/index.d.ts +3 -0
  77. package/dist/render/tags/index.d.ts.map +1 -0
  78. package/dist/render/tags/index.js +16 -0
  79. package/dist/render/tags/menu.d.ts +11 -0
  80. package/dist/render/tags/menu.d.ts.map +1 -0
  81. package/dist/render/tags/menu.js +74 -0
  82. package/dist/render/tags/related-posts.d.ts +11 -0
  83. package/dist/render/tags/related-posts.d.ts.map +1 -0
  84. package/dist/render/tags/related-posts.js +34 -0
  85. package/dist/render/tags/section-list.d.ts +9 -0
  86. package/dist/render/tags/section-list.d.ts.map +1 -0
  87. package/dist/render/tags/section-list.js +71 -0
  88. package/dist/render/tags/seo-head.d.ts +16 -0
  89. package/dist/render/tags/seo-head.d.ts.map +1 -0
  90. package/dist/render/tags/seo-head.js +73 -0
  91. package/dist/render/tags/toc.d.ts +15 -0
  92. package/dist/render/tags/toc.d.ts.map +1 -0
  93. package/dist/render/tags/toc.js +72 -0
  94. package/dist/render/types.d.ts +167 -0
  95. package/dist/render/types.d.ts.map +1 -0
  96. package/dist/render/types.js +13 -0
  97. package/dist/repository/bylines.d.ts +56 -0
  98. package/dist/repository/bylines.d.ts.map +1 -0
  99. package/dist/repository/bylines.js +284 -0
  100. package/dist/repository/domains.d.ts +67 -0
  101. package/dist/repository/domains.d.ts.map +1 -0
  102. package/dist/repository/domains.js +173 -0
  103. package/dist/repository/index.d.ts.map +1 -1
  104. package/dist/repository/index.js +7 -1
  105. package/dist/repository/media.d.ts +45 -0
  106. package/dist/repository/media.d.ts.map +1 -0
  107. package/dist/repository/media.js +248 -0
  108. package/dist/repository/menus.d.ts +58 -0
  109. package/dist/repository/menus.d.ts.map +1 -0
  110. package/dist/repository/menus.js +311 -0
  111. package/dist/repository/posts.d.ts +171 -0
  112. package/dist/repository/posts.d.ts.map +1 -0
  113. package/dist/repository/posts.js +791 -0
  114. package/dist/repository/site-files.d.ts +15 -0
  115. package/dist/repository/site-files.d.ts.map +1 -0
  116. package/dist/repository/site-files.js +73 -0
  117. package/dist/repository/tags.d.ts +51 -0
  118. package/dist/repository/tags.d.ts.map +1 -0
  119. package/dist/repository/tags.js +282 -0
  120. package/dist/repository/themes.d.ts +45 -0
  121. package/dist/repository/themes.d.ts.map +1 -0
  122. package/dist/repository/themes.js +285 -0
  123. package/dist/seo/hazo-seo-sitemap-source.d.ts +63 -0
  124. package/dist/seo/hazo-seo-sitemap-source.d.ts.map +1 -0
  125. package/dist/seo/hazo-seo-sitemap-source.js +85 -0
  126. package/dist/seo/index.d.ts +1 -0
  127. package/dist/seo/index.d.ts.map +1 -1
  128. package/dist/seo/index.js +6 -0
  129. package/dist/service/bylines.d.ts +21 -0
  130. package/dist/service/bylines.d.ts.map +1 -0
  131. package/dist/service/bylines.js +48 -0
  132. package/dist/service/cdn-hooks.d.ts +20 -0
  133. package/dist/service/cdn-hooks.d.ts.map +1 -0
  134. package/dist/service/cdn-hooks.js +38 -0
  135. package/dist/service/domains.d.ts +49 -0
  136. package/dist/service/domains.d.ts.map +1 -0
  137. package/dist/service/domains.js +98 -0
  138. package/dist/service/media.d.ts +79 -0
  139. package/dist/service/media.d.ts.map +1 -0
  140. package/dist/service/media.js +225 -0
  141. package/dist/service/menus.d.ts +43 -0
  142. package/dist/service/menus.d.ts.map +1 -0
  143. package/dist/service/menus.js +165 -0
  144. package/dist/service/posts.d.ts +70 -0
  145. package/dist/service/posts.d.ts.map +1 -0
  146. package/dist/service/posts.js +291 -0
  147. package/dist/service/seo-hooks.d.ts +18 -0
  148. package/dist/service/seo-hooks.d.ts.map +1 -0
  149. package/dist/service/seo-hooks.js +108 -0
  150. package/dist/service/site-files.d.ts +20 -0
  151. package/dist/service/site-files.d.ts.map +1 -0
  152. package/dist/service/site-files.js +71 -0
  153. package/dist/service/tags.d.ts +20 -0
  154. package/dist/service/tags.d.ts.map +1 -0
  155. package/dist/service/tags.js +41 -0
  156. package/dist/service/themes.d.ts +85 -0
  157. package/dist/service/themes.d.ts.map +1 -0
  158. package/dist/service/themes.js +416 -0
  159. package/dist/types/index.d.ts +953 -0
  160. package/dist/types/index.d.ts.map +1 -1
  161. package/dist/ui/content-status-badge.d.ts +7 -0
  162. package/dist/ui/content-status-badge.d.ts.map +1 -0
  163. package/dist/ui/content-status-badge.js +16 -0
  164. package/dist/ui/index.d.ts +1 -0
  165. package/dist/ui/index.d.ts.map +1 -1
  166. package/dist/ui/index.js +1 -0
  167. package/package.json +39 -28
package/CHANGE_LOG.md CHANGED
@@ -3,8 +3,954 @@
3
3
  All notable changes are documented here. This project follows
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## 2.16.0 — 2026-11-08
7
+
8
+ **⚠️ DDL required — site settings + custom CSS data layer (Phase 1b ticket 09): `hazo_blog_site_files`, a typed `SiteIdentity` settings shape, and `RenderSite.ogImage`.** This is the BACKEND/DATA layer — the admin UI, the two API routes, the `RenderContext.site` population, and the `<head>`-injection decision for custom CSS all ship in the BlogSketch platform repo's own commit for this ticket, per this package's usual split (see 2.13.0's menus entry for the same precedent).
9
+
10
+ - **New table** `hazo_blog_site_files` (both dialect files, mirrored into the platform's `migrations/0001_hazo_blog.sql`/`migrations/postgres/0001_hazo_blog.sql`): `id`, `scope_id` NOT NULL (platform-multi-tenant-first, same reasoning as `hazo_blog_menus`/`hazo_blog_site_theme` — no useful "global" custom CSS), `kind` (free-text, only `'custom_css'` used today — see `SiteFileKind`), `content`, `version`, `is_current`, `created_at`. **Versioning**: every save INSERTS a new row (never updates `content` in place) with `version` one higher than the previous current row for that `(scope_id, kind)`, flipping the old row's `is_current` off in the same call (`SiteFileRepository.setCurrentFile`) — full edit history for free, "at most one current row per (scope_id, kind)" enforced in application code, not a DB constraint (same convention as `hazo_blog_app_site_domains.is_primary`). Postgres also gets the strict (`scope_id` NOT NULL) RLS policy, same shape as `hazo_blog_menus`/`hazo_blog_site_theme`.
11
+ - **`createSiteFileRepository`/`createSiteFileService`** (`src/repository/site-files.ts`/`src/service/site-files.ts`, exported via the main entry) — `getCustomCss`/`getCustomCssFile`/`updateCustomCss`/`listCustomCssVersions`. `updateCustomCss` enforces a 256 KiB cap (`MAX_CUSTOM_CSS_BYTES`, `CustomCssTooLargeError`) — the only validation this layer does; this package does NOT sanitize/escape the CSS content itself (see `site-files.ts`'s own header comment: that boundary belongs to the host's render edge, same "service stores, render boundary escapes" split as MDX `content`/`rehype-sanitize`).
12
+ - **`SiteIdentity`** (`src/lib/site-identity.ts`, exported via `./lib` and the main entry) — a SECOND, separate typed settings shape (title/tagline/logo/favicon/ogImage/social/locale/timezone/analytics/ads), stored under its own `SITE_IDENTITY_KEY` (`"site_identity"`) plain key via the EXISTING `repository.getSetting`/`setSetting` mechanism `BlogSettings`/`BLOG_SETTINGS_KEY` ("display") already uses — not folded into that shape, per this ticket's own instruction (different concern). `resolveSiteIdentity(payload, fallback)` follows `resolveBlogSettings`'s exact "coerce untrusted payload, never throw, default per-field" contract. `contact_email` is deliberately NOT part of this shape — ticket 14's `contact_email` plain-key bridge already owns that, reused as-is by the platform's own admin UI rather than duplicated.
13
+ - **`RenderSite` gains `ogImage`** (`src/render/types.ts`) — the one `site.*` field ticket 09's Liquid-context bullet asks for that didn't already exist (`title`/`tagline`/`logo`/`favicon`/`locale`/`social`/`analytics` were all already there, from tickets 03/07). `{% seo_head %}`'s `og:image` fallback chain is now `post.seo.ogImage -> site.ogImage -> site.logo` (previously `post.seo.ogImage -> site.logo`) — additive, same `og:image` behaviour as before for any caller that never sets `site.ogImage`.
14
+ - Tests: `src/__tests__/site-identity.test.ts` (16 cases — pure `resolveSiteIdentity` coercion, including the untrusted-payload cap on `social`'s size), `src/__tests__/site-files.repository.integration.test.ts` (6 cases, real SQLite — versioning, history, scope isolation, cross-kind isolation), `src/__tests__/site-files.service.integration.test.ts` (7 cases, real SQLite — save/load round-trip, size cap, tenant isolation), plus 2 new cases in `src/__tests__/render.tags.test.ts` for the widened `og:image` fallback chain. 25 new tests, all passing; full suite unaffected (same pre-existing, unrelated v1.6/v2.0.0 schema-split failures every release since 2.10.0 has carried — zero regressions, +25 passing from this release).
15
+ - **Not done in this release (flagged, not silently deferred)**: no admin UI, no API routes, no `RenderContext.site` wiring, no custom-CSS `<head>` injection, no cache-purge-on-save (all of those ship in the platform repo's own commit); no rollback UI/API for custom CSS (`hazo_blog_site_files` keeps full history, but nothing reactivates an older version yet — same gap shape as 2.15.0's theme-version-rollback note); ad network ids (`SiteIdentity.ads`) are captured but not wired into any render/head-script-injection path yet (out of this ticket's own `site.*` Liquid-context scope, which lists only title/tagline/logo/favicon/social/locale/OG image).
16
+
17
+ Bumps 2.15.0 -> 2.16.0 (minor, new required DDL — see ⚠️ heading above).
18
+
19
+ ## 2.15.0 — 2026-11-08
20
+
21
+ **⚠️ DDL required — theme management data/service layer (Phase 1b ticket 06): `hazo_blog_themes`, `hazo_blog_theme_versions`, `hazo_blog_site_theme`, `theme.json` parsing/validation, and the render-integration answer to "which `themeDir` does `renderPage` use for site X?".** This is the BACKEND/DATA layer — the admin gallery UI is ticket 08's job, per this ticket's own scope note.
22
+
23
+ - **New tables**, both dialect files, mirrored into the platform's `migrations/0001_hazo_blog.sql`/`migrations/postgres/0001_hazo_blog.sql`: `hazo_blog_themes` (`id`, nullable `scope_id` — `NULL` = built-in/global, non-null = one site's own upload, visible only to it — `name`, `handle`, `version`, `author`, `licence`, `description`, `screenshots` JSON array, `source` `'built-in'|'upload'`, `storage_path`, `created_at`), `hazo_blog_theme_versions` (`id`, `theme_id` FK cascade, `version`, `storage_path`, `created_at` — full version history, not just the current one), `hazo_blog_site_theme` (`id`, `scope_id`, `theme_id` FK cascade, `settings` JSONB, `activated_at`). **One active theme per site** is enforced by a `UNIQUE(scope_id)` index on `hazo_blog_site_theme` PLUS upsert semantics in `ThemeRepository.setSiteTheme` (selects-then-updates the existing row for that scope rather than ever inserting a second one) — no separate `is_active` flag to go out of sync. No DB-level `UNIQUE(scope_id, handle)` on `hazo_blog_themes` — Postgres/SQLite both treat every `NULL` as distinct, so a composite unique index including the nullable `scope_id` would not actually stop two built-in themes sharing a handle; that invariant is enforced in application code (`findBuiltInByHandle`'s upsert-by-handle check) instead, same "invariant enforced in code, not SQL" convention this file already documents for `hazo_blog_menu_items.item_type`. Postgres also gets RLS: the dual-mode nullable-scope_id policy (same shape as `hazo_blog_categories`/`hazo_blog_themes` itself) on `hazo_blog_themes`, a join-based policy on `hazo_blog_theme_versions` (no `scope_id` column of its own — inherits visibility via `theme_id`), and the strict policy on `hazo_blog_site_theme`.
24
+ - **`BlogFileStorageProvider` gains two OPTIONAL methods, `get`/`list`** (additive, non-breaking) — needed because `hazo_blog/render`'s `renderPage` only ever reads `.liquid` files off a LOCAL filesystem directory (LiquidJS's `root` option is fs-only), so an uploaded theme stored in R2 has to be read back and materialized to local disk before it can render at all. `hazo_files`' `S3Provider` already implements both.
25
+ - **`theme.json` schema** (`src/lib/theme-schema.ts`, Zod, exported via `./lib` and the main entry) — `name`/`version` (loose semver)/`author`/`licence`/`description`/`screenshots`, a `settings[]` array (ticket's own exact field types: `colour`/`font`/`image`/`select`/`toggle`/`text`/`range`, with `options` required for `select`), and an optional `allowedSections` map (template name -> allowed section-type list). `parseThemeJson()` collects every validation issue, not just the first. `getAllowedSectionsForTemplate()` resolves one template's list (`null` = unrestricted) but **nothing calls it yet** — see the bridge note below. `buildThemeSettingsSchema()` builds a per-theme Zod schema (type-checked, partial, unknown keys dropped) for validating a settings PATCH.
26
+ - **Upload validation pipeline** (`src/lib/theme-validate.ts`'s `validateThemeFiles`) — the ticket's own exact checklist, in order: zip-slip path-safety re-check (absolute paths, `..`/empty segments, backslashes all rejected — defence in depth regardless of whether the caller, i.e. the platform's zip-extraction step, already checked), `theme.json` present + parses, all four required templates present (`home`/`post`/`page`/`404`), every `.liquid` file parses via `hazo_blog/render`'s OWN parser (new `parseLiquidSource()` in `src/render/engine.ts` — a pure in-memory syntax check, no file I/O, reusing the exact same `Liquid` construction + tag registrations real rendering uses, per the ticket's "don't hand-roll a second parser" instruction), total size under a configurable cap (default 20 MiB), and asset files restricted to CSS/JS/images/fonts by extension. Collects every issue into one `ThemeValidationError`, same "whole input is one unit" contract as `SectionValidationError`/`CustomFieldValidationError`. **Operates on an already-extracted `ThemeFile[]` — this package never unzips anything itself**; zip extraction is the platform repo's job (`uzip`, already available there, per the ticket's own instruction not to add a zip dependency here).
27
+ - **ISOLATION**: `theme-validate.ts` reaches `parseLiquidSource` via a LAZY `await import("../render/engine.js")` (a relative import of this package's own internal module, not the public `hazo_blog/render` subpath) — `liquidjs` is only ever loaded into memory when a theme upload is actually being validated, so a host that never installs/activates an uploaded theme still never pays for it, preserving `./render`'s own "nothing outside it pulls in `liquidjs`" boundary (2.12.0) even though theme management itself lives in the main, db-aware entry point.
28
+ - **`createThemeService(config, deps?)`** (`src/service/themes.ts`) — the orchestration layer: `listThemes`/`getTheme` (visibility-checked: built-in or the caller's own scope, `null` across a tenant boundary), `installBuiltInThemes` (the boot-scan bridge, below), `installThemeFromFiles` (validate -> store every file via `BlogConfig.themeStorage`'s provider -> register/bump a `BlogTheme` + `BlogThemeVersion` row — a second upload of the same theme name in the same scope is a new VERSION, not a duplicate), `getActiveTheme`/`activateTheme`/`deactivateTheme` (activating seeds `settings` from `theme.json`'s field defaults UNLESS re-activating the theme already active for that scope, which keeps its existing settings untouched), `updateThemeSettings` (schema-validated partial merge, `ThemeSettingsValidationError`/`ThemeNotActiveError`), and **`resolveRenderThemeDir({ previewThemeId }, req)`** — the ticket's own central render-integration question. A built-in theme's `storage_path` IS already a local directory (render directly, no materialization); an uploaded theme is downloaded (`provider.list`/`get`) into `BlogConfig.themes.renderCacheDir` (default `os.tmpdir()/hazo_blog_themes_cache/<themeId>/<version>`, cached behind a `.ok` marker written only after every file succeeds, so a crash mid-download retries rather than serving a half-written cache) before being handed back as a real `themeDir`. `previewThemeId` — when visible to the caller's scope — wins over the persisted active theme and is NEVER written to `hazo_blog_site_theme`; session-scoping the preview (cookie/query param) is the host's job, this function just honours whichever id it's handed.
29
+ - **BUILT-IN THEME SCAN BRIDGE**: `installBuiltInThemes` looks for an installed npm package named `BlogConfig.themes.builtInPackageName` (default `"hazo_blog_themes"`) and walks its `themes/<handle>/{theme.json,templates/*.liquid,...}` subdirectories, validating and `upsertBuiltInTheme`-ing each one. **That package does not exist yet — ticket 07 builds it with real themes inside.** Until then (or for any host that never installs it), the scan degrades gracefully: logs once via `hazo_logs`, returns `{ available: false, installed: [] }`, never throws — same bridge shape `../render/fallback-theme.ts` (ticket 03) and `../lib/sections.ts` (ticket 05) already use for "the real thing doesn't exist yet." `deps.resolveThemesPackageRoot` is the injectable seam (same convention as `../service/media.ts`'s `deps.processMediaVariants`) that lets this package's OWN test suite point the scan at a local fixture directory (`src/__tests__/fixtures/hazo_blog_themes_fixture/`) with the same shape, rather than needing a real installed package.
30
+ - **`allowedSections`-per-template, EXPLICITLY NOT wired into enforcement**: `theme.json` now parses and stores the field (`getAllowedSectionsForTemplate`), but `../lib/sections.ts`'s `validateSections` (ticket 05) still allows every `SECTION_TYPE_REGISTRY` type on every page, unchanged. Wiring real enforcement would mean threading the ACTIVE theme's parsed `theme.json` into every `createPost`/`updatePost` call — today those only ever see `BlogConfig`, never a resolved theme — a genuine cross-cutting change to the post-save path, bigger than "parse one more field," so it's handed off to ticket 07/08 per this ticket's own explicit instruction, not half-wired here.
31
+ - Tests: `src/__tests__/theme-schema.test.ts` (13 cases), `src/__tests__/theme-validate.test.ts` (15 cases — including the zip-slip rejections and multi-issue collection), `src/__tests__/themes.repository.integration.test.ts` (11 cases, real SQLite), `src/__tests__/themes.service.integration.test.ts` (14 cases, real SQLite + a fake in-memory `BlogFileStorageProvider` — built-in scan against the fixture package, install-from-files standing in for "from zip," activate/deactivate, settings save/load + validation, cross-scope activation/preview rejection, and the actual local-disk materialization of an uploaded theme). 53 new tests, all passing; full suite unaffected (same pre-existing, unrelated i18n/translation-feature test failures every release since 2.10.0 has carried — verified unchanged with this release's changes reverted via `git stash` on just the files this release touched — zero regressions, +53 passing).
32
+ - **Not done in this release (flagged, not silently deferred)**: no admin gallery/install/activate UI (ticket 08's own surface, per this ticket's scope note) — the platform repo's own commit for this ticket ships the manage API routes, a minimal settings-form auto-generation component, and the `?theme_preview=` wiring into its public render pipeline, but not a themes BROWSE screen; no theme rollback API (`hazo_blog_theme_versions` keeps full history, but nothing lets an operator reactivate an older version yet — straightforward to add on top of the existing version rows, just not asked for here); `allowedSections` enforcement (see above).
33
+
34
+ Bumps 2.14.0 -> 2.15.0 (minor, new required DDL — see ⚠️ heading above).
35
+
36
+ ## 2.14.0 — 2026-11-08
37
+
38
+ **⚠️ DDL required — section types + section builder (Phase 1b ticket 05): `sections_json` on `hazo_blog_posts`, a 12-type section registry, and the `{% section_list %}` Liquid tag.** Operators compose page layouts (hero/text/image+text/posts grid/CTA/FAQ/quote form/testimonials/logos/contact/stats/features) as an ordered, hide-able list stored alongside the post, rendered through themed partials with a safe generic fallback.
39
+
40
+ - **New column** `sections_json` on `hazo_blog_posts` — `JSONB NOT NULL DEFAULT '[]'::jsonb` (Postgres, also an idempotent `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` for a pre-2.14.0 table) / `TEXT NOT NULL DEFAULT '[]'` (SQLite, in the `CREATE TABLE` directly — SQLite/add-on-mode installs are expected fresh, same convention `status`/`category_id` already established for this table). Stores `[{type, version, key, settings, hidden}]`, in display order. Lives on the POST row (not `hazo_blog_revisions`) — layout composition is a page property, not versioned MDX content, same reasoning `template` already carries.
41
+ - **`SECTION_TYPE_REGISTRY`** (`src/lib/sections.ts`, exported via `./lib` and the main `.` entry) — the 12 ticket-specified types, each with a Zod settings schema, a `defaultSettings()` factory (valid placeholder content, not empty objects — a freshly-added section is immediately previewable/savable), a display `label`, a `lucide-react` icon NAME (string, framework-free), and a `summarize()` one-liner for the admin card list. `validateSections(value)` validates+normalizes an untrusted `sections_json` array against the registry (unknown `type`, missing `key`, or a per-type schema failure all collect into one `SectionValidationError.issues[]` — same "all failures reported, not just the first" contract as `CustomFieldValidationError`); `isSafeUrl()` (`src/lib/safe-url.ts`, same allowlist as `../repository/menus.ts`'s `isSafeMenuUrl`) rejects `javascript:`/other unsafe schemes on URL-shaped settings (`hero.ctaUrl`, `cta.buttonUrl`, `logos[].url`) at WRITE time.
42
+ - **`PostService.createPost`/`updatePost`** now validate `sections_json` unconditionally (engine-defined, not host-configured like `customFieldSchemas`) via `validateSections()` before any repository write, persisting the normalized (Zod-defaulted) shape. `UpdatePostInput.sections_json` omitted = unchanged, same convention as every other optional field — an editor save that never touches sections cannot accidentally clear them.
43
+ - **`{% section_list %}` Liquid tag** (`src/render/tags/section-list.ts`, registered alongside tickets 03/04's tags) — iterates `page.sections` in order, skips `hidden` entries, and renders each visible one through the active theme's `sections/{type}.liquid` partial (via `this.liquid.renderFile()`, the SAME engine instance, so the partial sees the full render context plus a `section` key) — falling back to a generic card (type name + an HTML-escaped settings dump) when the theme has no matching partial. **Bridge, same shape as ticket 03's fallback-theme bridge**: the built-in `FALLBACK_THEME_DIR` ships no `sections/*.liquid` partials at all, so every section renders as the fallback card there today — expected, not a bug, until a real theme (ticket 07) ships matching partials.
44
+ - **Built-in fallback theme** (`src/render/fallback-theme/templates/default.liquid`) now calls `{% section_list %}` right after `{{ content }}`, on the SAME line (`{{ content }}{% section_list %}`, no separating newline) specifically so the existing byte-for-byte-identical invariant (ticket 03) still holds for every row with an empty `sections_json` (the default, i.e. every pre-2.14.0 post/page and anything a host never explicitly adds sections to) — verified by keeping that exact test passing, plus a new one proving sections DO render once a page actually has them.
45
+ - **`allowedSections`-per-template bridge, explicitly NOT built here**: there is no theme management system yet (ticket 06/07 build per-site `theme.json` storage). Per the ticket's own stated default, every registered type is allowed on every page today — `SECTION_TYPES` is the full, unnarrowed list. When ticket 06/07 lands real `theme.json` reading, `allowedSections` narrows this per template; nothing in `SECTION_TYPE_REGISTRY`/`validateSections` needs to change for that.
46
+ - Tests: `src/__tests__/sections.test.ts` (18 cases — registry shape, every type's `defaultSettings()` validates against its own schema, valid-passes/invalid-rejected per type, unsafe-URL-scheme rejection, multi-entry issue collection), `src/__tests__/render.section-list.test.ts` (7 cases — order preserved, hidden skipped, fallback card for a theme with no partial, a real theme partial rendered instead when one exists, the partial seeing the rest of the render context, fallback+real-partial mixed on one page), `src/__tests__/posts.sections.test.ts` (7 cases — real-sqlite-adapter CRUD via `PostService`: create/round-trip, default-to-`[]`, reject-with-no-row-written, unsafe-URL rejection, update-replaces-and-revalidates, omitted-field-leaves-unchanged, reorder/duplicate/hide round-tripping exactly as the admin builder would send them). 34 new tests (2.14.0's own, plus 2 pre-existing `toPost` duplicates in `src/repository/bylines.ts`/`src/repository/tags.ts` updated to parse the new column), all passing; full suite unaffected (same pre-existing v1.6/v2.0.0 schema-split failures as every release since 2.10.0 — zero regressions, +34 passing from this release).
47
+ - **Not done in this release (flagged, not silently deferred):** no real theme ships `sections/*.liquid` partials (every section renders as the fallback card on every live site today, per the bridge above); no `quote_form` section actually embeds a QuoteDash form (`quotedashCategory` is just captured data for a future embed renderer — the embed itself is `08.quotedash`'s own surface); the platform's own admin section-builder UI, Post PATCH wiring, and preview button ship in the platform repo's own ticket 05 commit, not here.
48
+
49
+ Bumps 2.13.0 -> 2.14.0 (minor, new required DDL — see ⚠️ heading above).
50
+
51
+ ## 2.13.0 — 2026-11-08
52
+
53
+ **⚠️ DDL required — menu system (Phase 1b ticket 04): `hazo_blog_menus` + `hazo_blog_menu_items`, a manage service, and the `{% menu "handle" %}` Liquid tag.** Named navigation menus (e.g. "main", "footer") with reference items (auto-resolved title+URL from a live post/page/category, dropped when unpublished) or custom items (own label+URL), nested to unlimited depth, reordered via drag or indent/outdent.
54
+
55
+ - **New tables** `hazo_blog_menus` (`id`, `scope_id`, `name`, `handle`, unique per `(scope_id, handle)`) and `hazo_blog_menu_items` (`id`, `menu_id`, `scope_id`, `parent_id` self-referencing for nesting, `position`, `item_type` `reference`/`custom`, `ref_type`/`ref_id` for reference items, `label`/`url`/`css_class`/`target`) — both dialect files, mirrored into the platform's `migrations/0001_hazo_blog.sql`/`migrations/postgres/0001_hazo_blog.sql` in the same pass. **`scope_id` is NOT NULL on both** — a deliberate deviation from this file's own "every other table carries a nullable `scope_id`" convention, matching `hazo_blog_posts`/`hazo_blog_bylines`/`hazo_blog_app_site_domains` instead: menus are multi-tenant-platform-first (there's no useful "global menu" the way a shared legacy category/tag can be), and v1.6 add-on consumers (GoTimer/kinstripe, pinned `~1.6`) never pull this DDL at all, so there's no back-compat reason to allow `NULL` here either. `ON DELETE CASCADE` on both `menu_id` and `parent_id` means deleting a menu or any item removes its whole subtree in one statement. Postgres also gets `ENABLE`/`FORCE ROW LEVEL SECURITY` + a strict `tenant_isolation` policy on both new tables, same convention as every other NOT-NULL-`scope_id` table since 2.11.0.
56
+ - **`createMenuRepository(adapter, scope)`** (`src/repository/menus.ts`) — CRUD for menus + items, `reorderMenuItems(menuId, items)` (batched `{id, parentId, position}` — drives both plain drag-reorder and indent/outdent from the same primitive), shape validation (`MenuItemValidationError`: custom items need `label`+`url`, reference items need `ref_type`+`ref_id`), handle-uniqueness (`MenuHandleConflictError`), and self-parent/cross-menu-parent rejection. `MenuNotFoundError`/`MenuItemNotFoundError` for unknown ids, same "throw on write, scope-filtered lookups" convention as `BylineRepository`/`DomainRepository`.
57
+ - **`createMenuService(config)`** (`src/service/menus.ts`) — thin per-request wrapper (same `resolvePostScope` scope resolution as bylines/domains) plus the one piece of real logic this ticket needed: **`resolveMenuForRender(handle, req)`**, which walks a menu's items and, for each reference item, looks up the live post/page/category it points at — dropping the item (and its entire subtree, since nothing re-parents onto a dropped id) when the target is missing or unpublished (`isPostLive`). Returns `ResolvedMenuItem[]` (nested, render-ready). `BlogConfig.menus.resolveReferenceUrl` optionally overrides the built-in URL shape (`/${slug}` for post/page, `/category/${slug}` for category — already the platform's own public routing convention, so most hosts never need to set this). `buildResolvedMenuTree` (the tree-builder) is exported standalone and unit-tested without a DB.
58
+ - **`{% menu "handle" %}` Liquid tag** (`src/render/tags/menu.ts`, registered alongside ticket 03's five tags) — renders a nested `<nav><ul><li>` from `RenderContext.menus[handle]`, an ALREADY-resolved tree the host builds via `resolveMenuForRender`/`resolveAllMenusForRender` before calling `renderTemplate()`/`renderPage()` (same "no db/fs/network inside a template" boundary as `related_posts`/`faq`). Active item gets `aria-current="page"` (compared against `request.path`, already in context); a `target="_blank"` item automatically gets `rel="noopener noreferrer"`. `RenderMenuItem` is a deliberately separate, structurally-identical type from `ResolvedMenuItem` (not a shared import) — same "`src/render/*` stays self-contained" reasoning as every other `Render*` type.
59
+ - Tests: `src/__tests__/menus.repository.integration.test.ts` (29 cases — CRUD, nesting, reorder/indent/outdent, validation, tenant isolation, real SQLite via the `better-sqlite3` driver so `ON DELETE CASCADE` is actually exercised — the default `sql.js` driver these integration tests normally run under does not enforce foreign keys at all), `src/__tests__/menus.service.integration.test.ts` (14 cases — reference resolution for published/draft/missing posts and categories, label overrides, dropped-subtree behaviour, nesting/ordering, `resolveAllMenusForRender`), plus 7 new cases in `src/__tests__/render.tags.test.ts` (`buildMenu`'s nesting/active-state/escaping, and the tag wired through a real Liquid template). 50 new tests, all passing; full suite unaffected (same pre-existing unrelated failures as 2.12.0 — see that entry).
60
+ - **Not done in this release (flagged, not silently deferred):** no `next/menu-routes.ts` API-key route factory (the `createBylineManageRoutes`-shape documented in this package's own CLAUDE.md) — every admin resource shipped since ticket 14 (`domains`, `bylines`, `categories`/`tags`) is actually consumed by the BlogSketch platform via its OWN `/api/*` Next.js routes calling `createXService`/`createXRepository` directly (with the platform's own membership check), never through this package's API-key route factories, so menus follows that same real precedent rather than building an unused route factory. (The platform's own public-page render pipeline wiring + admin UI ship in the platform repo's own ticket 04 commit.)
61
+
62
+ Bumps 2.12.0 -> 2.13.0 (minor, new required DDL — see ⚠️ heading above).
63
+
64
+ ## 2.12.0 — 2026-11-07
65
+
66
+ **Additive — LiquidJS render engine behind a new isolated `./render` export boundary (Phase 1b ticket 03, critical path for themed public sites).** `liquidjs` is a real `dependencies` entry now, but it is NEVER pulled into a consumer's bundle unless that consumer explicitly does `import ... from "hazo_blog/render"` — nothing under `src/index.ts`, `src/next/index.ts`, or `src/ui/index.ts` imports from `src/render/*`, mirroring the isolation principle the package's own `./ui` boundary already established (see `src/render/index.ts`'s header comment). Add-on consumers (GoTimer, kinstripe) pinned to `~1.6` are completely unaffected; even a `2.x`+ consumer that only imports `.`/`./next`/`./ui` never loads `liquidjs`.
67
+
68
+ - **`renderTemplate(themeDir, templateName, context)`** (`src/render/engine.ts`) — the core primitive: renders `templates/{templateName}.liquid` under a theme directory with a `RenderContext`. One `Liquid` engine instance (with its own parsed-template cache) is kept in-memory per `themeDir`; `invalidateThemeCache(themeDir)` drops it so the next render re-reads from disk — call this whenever a theme's files change (no theme-change event source exists yet; ticket 06/07 build real theme storage on top of this).
69
+ - **`resolveTemplateName(themeDir, { type, template })`** (`src/render/resolve.ts`) + **`renderPage(themeDir, { type, template }, context)`** (`src/render/engine.ts`) — the template resolution chain: `templates/{page.template}.liquid` -> `templates/{type}.liquid` -> `templates/default.liquid` -> `templates/404.liquid`. `renderPage` reports which template actually won and whether it fell through to `404`.
70
+ - **`RenderContext`** (`src/render/types.ts`): `content` (pre-rendered HTML — this is the two-pass pipeline: MDX compiled to HTML first, then Liquid renders the page template around it), `post` (title/slug/dates/categories/tags/customFields/seo overrides, plus `faq`/`related` — see below), `site` (title/tagline/logo/favicon/locale/social/analytics), `page` (for page-type content), `request` (url/path/segments). Liquid templates get ONLY this object — no db, filesystem, or network handle is ever placed in scope; the engine itself reading `.liquid` files off `themeDir` is loading the template source, not something a template/tag can reach at render time.
71
+ - **Judgment call, flagged rather than silently decided:** the ticket's context-contract bullet doesn't literally list data for `{% related_posts %}` or `{% faq %}`. Rather than have those tags reach outside `context` (which would violate the "no db/fs/network from inside a template" invariant), both are modelled as pre-computed fields nested under `post` — `post.related: RenderRelatedPost[]` and `post.faq: RenderFaqItem[]` (same `question`/`answer` shape as this package's existing `FaqItem`) — populated by the host the same way `content` itself already is.
72
+ - **Platform Liquid tags** (`src/render/tags/*.ts`, registered via `registerPlatformTags()`): `{% seo_head %}` (canonical link, OG/Twitter meta, BlogPosting/WebSite JSON-LD, per-entry `noindex`/title/description/image overrides), `{% image media size: "..." %}` (responsive `<picture>` with AVIF/WebP `<source>`s filtered to the requested size + a plain `<img>` fallback), `{% related_posts limit: N %}`, `{% toc %}` (table of contents extracted from the `<h2>`/`<h3>` headings already in `content` — decodes the small set of HTML entities `escapeHtml()` itself produces before slugifying/re-escaping, to avoid double-escaping and junk ids like `conclusion-amp-next-steps`), `{% faq %}` (accordion + FAQPage JSON-LD). Every tag's HTML-escaping is manual (`src/render/html.ts`) since `emitter.write()` bypasses Liquid's own output escaping. Every tag also exports its pure builder function (`buildSeoHead`, `buildImageTag`, `buildRelatedPosts`, `buildToc`/`extractHeadings`, `buildFaq`) so it's unit-testable without parsing any Liquid markup.
73
+ - **Built-in fallback/bridge theme** (`src/render/fallback-theme.ts` + `src/render/fallback-theme/templates/{default,404}.liquid`, `FALLBACK_THEME_DIR`): there is no theme management system yet (ticket 06/07 build per-site theme storage + the real "trades" theme on top of this engine). This ships JUST `default.liquid` (literally `{{ content }}`, no chrome at all) + `404.liquid` so a host with no real theme can still route its public pages through `renderTemplate()`/`renderPage()` instead of a hand-rolled MDX-to-React pipeline, producing byte-for-byte the same HTML fragment as plain MDX rendering did before. `.liquid` files aren't TypeScript sources, so `npm run build` now also runs `scripts/copy-render-assets.mjs` to copy them into `dist/render/fallback-theme/` alongside the compiled JS.
74
+ - Tests: `src/__tests__/render.resolve.test.ts` (6 cases — the full resolution chain + edge cases), `src/__tests__/render.engine.test.ts` (7 cases — correct HTML structure from a minimal test theme, `renderPage`'s resolution+404 fallthrough, the in-memory per-theme cache AND its invalidation, including that two themes' caches are independent), `src/__tests__/render.tags.test.ts` (18 cases — every tag's pure builder tested in isolation with known input -> expected output, PLUS one render-through-real-Liquid test per tag proving `registerPlatformTags()` wiring), `src/__tests__/render.fallback-theme.test.ts` (2 cases — the bridge theme reproduces plain content with no chrome, and has a working `404.liquid`). 35 new tests, all passing; full suite 530 passing / 52 failing (same pre-existing v1.6/v2.0.0 schema-split failures as 2.10.0/2.11.0 — zero regressions, +35 passing from this release).
75
+ - `test-app/app/render-demo/page.tsx` + `test-app/themes/demo/templates/*.liquid` — a server-rendered manual sanity-check page exercising every platform tag and both the demo theme and the fallback bridge theme side by side.
76
+ - **Not done in this release (flagged, not silently deferred):** no host actually calls this engine yet from a Next.js route in THIS package — `createBlogPostPage`/`createBlogIndexPage` (`src/next/pages.tsx`) are untouched, still rendering via `BlogContent`'s React/MDX pipeline. Wiring a real host's public routes to this engine (with the fallback theme as a bridge) is the BlogSketch platform app's own ticket 03 work, done in that repo, not here.
77
+
78
+ Bumps 2.11.0 -> 2.12.0 (minor, additive — no DDL, no removed/renamed exports).
79
+
80
+ ## 2.11.0 — 2026-11-06
81
+
82
+ **⚠️ DDL required — Postgres Row-Level Security policies (Phase 1a issue 17, platform's tenant-isolation release gate).** Defence-in-depth backstop on top of the repository layer's existing scope_id filtering — not a behaviour change for any consumer that doesn't set `app.current_scope_id`, until that consumer's Postgres has this migration applied.
83
+
84
+ - **New Postgres-only DDL section** at the end of `db_setup_postgres.sql` (mirrored byte-for-byte into `migrations/db_setup_postgres.sql`, same convention this file already follows) — no SQLite equivalent exists or is attempted; this establishes the precedent for any future Postgres-only feature in this file (its own section, clearly headed, dialect files allowed to diverge only there).
85
+ - `ENABLE ROW LEVEL SECURITY` + `FORCE ROW LEVEL SECURITY` + one `tenant_isolation` policy on every `hazo_blog_*` table: `hazo_blog_categories`, `hazo_blog_tags`, `hazo_blog_posts`, `hazo_blog_revisions`, `hazo_blog_post_tags`, `hazo_blog_bylines`, `hazo_blog_post_bylines`, `hazo_blog_media`, `hazo_blog_media_usage`, `hazo_blog_settings`, `hazo_blog_app_site_domains`. `FORCE` matters: the app's Postgres role OWNS these tables in production (`infra/provision.sh`'s `createdb -O`), and table owners bypass RLS by default — verified by hand against a real Postgres that without `FORCE` the owning role saw every tenant's rows regardless of the GUC.
86
+ - New `hazo_blog_current_scope_id()` SQL function wraps `current_setting('app.current_scope_id', true)` and collapses it through `NULLIF(..., '')`. Found by testing against a real Postgres, not assumed: once any transaction on a pooled connection has done `SET LOCAL app.current_scope_id = '...'` and committed, the GUC resets to `''` (empty string) for the rest of that session, NOT back to SQL NULL — a naive `IS NOT NULL` fail-safe check would treat a later request that forgot to set the GUC as "scoped" and leak every NULL-scope_id (global) row. The function makes "unset" mean the same thing on a fresh connection and a recycled one.
87
+ - Fail-safe default is **deny all** when the GUC is unset/empty (not "allow all") — the safer of the two options, on the reasoning that a forgotten `SET LOCAL` should produce a loud empty result, not a cross-tenant leak.
88
+ - Nullable-`scope_id` tables (`categories`/`tags`/`settings`) keep this package's own documented "NULL = legacy/global, visible to every tenant" rule (same rule `assertCategoryVisible`/the tags repository already enforce in application code) — RLS mirrors that, it doesn't invent a stricter rule. NOT-NULL-`scope_id` tables get strict equality. The two join tables with no `scope_id` column of their own (`hazo_blog_post_tags`/`hazo_blog_post_bylines`) reach through `post_id` to `hazo_blog_posts.scope_id`.
89
+ - **Compatibility note:** this assumes a consumer that always sets `app.current_scope_id` per request (a genuinely multi-tenant deployment). A single-tenant/add-on-mode consumer that never sets the GUC would see zero rows under these policies, not its own data. GoTimer/kinstripe (add-on mode) are pinned to `~1.6` and never pull this DDL, so they're unaffected; flag this again before ever applying this migration to a single-tenant deployment on `2.x`+.
90
+ - **App-side wiring is NOT included in this release.** These policies are only a real backstop once the app issues `SET LOCAL app.current_scope_id = '<scope>'` inside the transaction wrapping each request. The BlogSketch platform app does not yet do this (see Phase 1a issue 17's write-up / `docs/TENANT_ISOLATION_TESTING.md` in the platform repo) — query-level scope filtering, already in place, is what's actually protecting production right now. The policies themselves are proven correct independently via direct SQL (see below), which is what "RLS tested: direct SQL query ... returns no rows" asked for.
91
+ - New `src/__tests__/rls.postgres.integration.test.ts` — real Postgres integration test (8 cases), env-gated on `TEST_POSTGRES_URL` (skipped, not failed, when unset — this is the first env-gated/Postgres-only integration test in this package, establishing that precedent; every other integration test here runs against SQLite). Creates its own throwaway role + database owned by that role (mirrors production's ownership shape, so the FORCE-RLS behaviour is actually exercised, not bypassed by a superuser/owner shortcut), applies the real `db_setup_postgres.sql`, then proves: own-scope + global visibility, cross-tenant SELECT/UPDATE/INSERT all blocked, join-table policies scoped correctly, the fail-safe-deny-when-unset behaviour (including the empty-string gotcha above), and that `FORCE ROW LEVEL SECURITY` is actually set on disk (`pg_class.relforcerowsecurity`). Run locally with `TEST_POSTGRES_URL=postgres://user@host:port/db npm test -- rls.postgres`.
92
+ - **Not in scope for this release** (flagged, not silently skipped): `hazo_seo_*` tables are also multi-tenant with a nullable `scope_id` and have no RLS — out of scope for this ticket (a different repo/package), proposed as a follow-up.
93
+
94
+ **Bug fix, found by this same ticket's real-DB (not mocked) end-to-end testing — `PostRepository.createPost` violated its own foreign key under real FK enforcement.** `createPost` (`src/repository/posts.ts`) inserted the new post's first `hazo_blog_revisions` row (`post_id` is `NOT NULL REFERENCES hazo_blog_posts(id)`) BEFORE inserting the `hazo_blog_posts` row that `post_id` points at — an immediate FK violation under a connection with `PRAGMA foreign_keys = ON`, which is `hazo_connect`'s `BetterSqlite3Adapter` default AND this platform's own `.env.local` (`HAZO_CONNECT_SQLITE_DRIVER=better-sqlite3`). Every existing test in this package (and in the platform) that exercises `createPost` either mocks the service entirely or runs against whichever driver `createHazoConnect`'s raw factory defaults to when none is specified (sql.js/WASM, which doesn't enforce the pragma the same way) — so this never surfaced until Phase 1a issue 17's comprehensive suite created a REAL post through a REAL `better-sqlite3` connection for the first time anywhere in this package's or the platform's test history. Fixed by reordering: insert the post row first (`draft_revision_id: null`), then the revision, then `updateById` the post's `draft_revision_id` to point at it. No interface/behaviour change for any caller — same inputs produce the same `Post` shape back; this is a patch-level fix, not a DDL/API change. New coverage is the tenant-isolation suite itself (platform repo), not a new test in this package — flagged here rather than silently fixed with no trace.
95
+
96
+ Bumps 2.10.0 -> 2.11.0 (minor, new required DDL — see ⚠️ heading above; the `createPost` fix above is a patch-level fix riding along in this same release rather than a separate point release).
97
+
98
+ ## 2.10.0 — 2026-11-06
99
+
100
+ **Additive — v2 tags + minimal custom-fields schema validation (Phase 1a issue 15, post editor).** Two gaps the platform's post editor needed closed:
101
+
102
+ - **Tags on v2 posts.** `hazo_blog_post_tags`/`hazo_blog_tags` already existed (legacy v1.6 tables, `post_id` already pointing at the current `hazo_blog_posts`, surrogate `id` PK) — reused AS-IS, no new DDL. New `src/repository/tags.ts`/`src/service/tags.ts` (`createTagRepository`/`createTagService`), mirroring `./bylines.ts`'s shape exactly: `listTags` (scope-visible — this tenant's own tags PLUS global `scope_id: null` ones, since `hazo_blog_tags.scope_id` is nullable and its unique index doesn't dedupe NULLs), `getOrCreateTag` (select/create UX, own-scope-first lookup), `createTag`, `assignTagToPost`/`removeTagFromPost` (idempotent), `listTagsForPost`, `listPostsByTag`. Because `hazo_blog_post_tags` has NO `scope_id` column of its own (unlike `hazo_blog_post_bylines`), every cross-table reference here is visibility-checked in code — a tag with a real, DIFFERENT non-null `scope_id` is treated as not found, same cross-tenant-reject rule `assertCategoryVisible` (issue 12's security fix) set for `category_id`. New errors: `TagNotFoundError`, `PostNotFoundForTagError`.
103
+ - **Minimal custom-fields schema validation.** `Post.custom_fields` stays an untyped JSONB blob — this adds an OPTIONAL per-`type` Zod hook, not a schema-authoring product: `BlogConfig.customFieldSchemas?: Record<string, ZodTypeAny>`. `createPostService.createPost`/`updatePost` validate `input.custom_fields` against the schema for the post's `type` (the input's `type` on create, the EXISTING post's `type` on update — never the input's, since `UpdatePostInput` has no `type` field) when both a schema is configured AND `custom_fields` is actually supplied; a mismatch throws the new `CustomFieldValidationError` (carries every failing field via Zod's issues, not just the first) and blocks the ENTIRE save, same all-or-nothing contract as a stale `expectedRev`. No schema configured for a `type` = exactly today's untyped behaviour (no-op). `zod` is now a real dependency of this package (`^4.6.7`) — only ever imported type-only (`ZodTypeAny`) in `types/index.ts`; `service/posts.ts` never imports zod itself, just calls `.safeParse()` structurally on whatever the host hands it, so this package never pins the host's zod version at runtime.
104
+
105
+ - **`src/repository/tags.ts`** (new), **`src/service/tags.ts`** (new): see above.
106
+ - **`src/service/posts.ts`**: `validateCustomFields` + `CustomFieldValidationError`, wired into `createPost`/`updatePost`.
107
+ - **`src/types/index.ts`**: `Tag`, `CreateTagInput`; `BlogConfig.customFieldSchemas`.
108
+ - **`src/index.ts`**: new exports — `createTagRepository`, `createTagService`, `TagNotFoundError`, `PostNotFoundForTagError`, `CustomFieldValidationError`.
109
+ - Tests: `src/__tests__/tags.repository.integration.test.ts` (24 cases — CRUD, assignment, `listPostsByTag`, global/null-scope tag visibility, tenant isolation, mirroring `bylines.repository.integration.test.ts`'s style) and `src/__tests__/posts.custom-fields.test.ts` (8 cases — valid passes, invalid rejected with every failing field, no-schema-configured and omitted-field no-ops, update validates against the post's OWN type). Full suite: 495 passing / 52 failing (same pre-existing v1.6/v2.0.0 schema-split failures as 2.9.1 — zero regressions, +32 passing from this release).
110
+
111
+ Bumps 2.9.1 -> 2.10.0 (minor, additive — no DDL).
112
+
113
+ ## 2.9.1 — 2026-11-06
114
+
115
+ **Security fix — domain-claim squatting (⚠️ DDL required — index change only, no column/table change).**
116
+ `addDomain` blocked registration on ANY existing row for a domain string,
117
+ verified or not — a tenant could squat an unverified claim on a domain it
118
+ doesn't own and permanently block the real owner from ever registering it
119
+ (denial of service). `hazo_blog_app_site_domains`'s global unique index on
120
+ `domain` is now a PARTIAL unique index (`WHERE verified`/`WHERE verified =
121
+ 1`) so only a verified row blocks registration; multiple unverified claims
122
+ on the same domain string can coexist. `findDomainByHost`/
123
+ `resolveScopeByDomain` now only ever return a VERIFIED row (previously
124
+ returned any match, relying on the caller to check `.verified` — simplified
125
+ to the stricter contract). `verifyDomain` now deletes losing unverified
126
+ sibling claims on the same domain string once one of them actually proves
127
+ ownership (new `deleteCompetingUnverifiedClaims`). Postgres/SQLite hosts
128
+ upgrading need to drop the old plain unique index and create the new
129
+ partial one (see `db_setup_*.sql`'s updated `CREATE UNIQUE INDEX`
130
+ statements) — no data loss, no column change.
131
+
132
+ ## 2.9.0 — 2026-11-06
133
+
134
+ **Additive — `getPostBySlug` for the v2 `Post` model (Phase 1a issue 14).**
135
+ `PostRepository`/`PostService` gain `getPostBySlug(slug, opts?: { locale?
136
+ })` — the v2 content model (`hazo_blog_posts`) had no slug-based lookup at
137
+ all before this (only `getPostById`, plus pagination-oriented `listPosts`),
138
+ which the platform's public `/{slug}` route resolver needs to turn a URL
139
+ slug into a post/page. Status-agnostic, same convention as `getPostById` —
140
+ callers check `isPostLive` themselves. Mirrors the legacy
141
+ `BlogRepository.getPostBySlug`'s exact `locale IS NULL` vs. `locale = ?`
142
+ query-building convention (`eq` with a literal `null` doesn't match on
143
+ Postgres). No DDL change — reuses the existing
144
+ `(scope_id, slug, COALESCE(locale, ''))` unique index.
145
+
146
+ - **`src/repository/posts.ts`**: new `getPostBySlug` on `PostRepository` +
147
+ implementation.
148
+ - **`src/service/posts.ts`**: new `getPostBySlug` on `PostService`, thin
149
+ passthrough to the repository.
150
+ - Tests: 2 new cases in `posts.repository.integration.test.ts` (default-
151
+ locale resolution + tenant isolation; locale-variant resolution). Full
152
+ suite: 463 passing / 52 failing (same pre-existing failures as 2.8.0).
153
+
154
+ ## 2.8.0 — 2026-11-06
155
+
156
+ **⚠️ DDL required — app site domains (Phase 1a issue 14).** Adds
157
+ `hazo_blog_app_site_domains` (both dialects + `migrations/`): `id`,
158
+ `scope_id` (NOT NULL — see table comment), `domain`, `verified`,
159
+ `is_primary`, `created_at`. `domain` is unique GLOBALLY, not per-scope (a
160
+ domain can only ever belong to one tenant — see the table comment for why
161
+ this deviates from this package's usual `(scope_id, slug)` compound
162
+ uniqueness). Postgres hosts need the new `CREATE TABLE`/index statements
163
+ applied; SQLite hosts just re-run `db_setup_sqlite.sql`.
164
+
165
+ This is the first unauthenticated-public-facing table this package ships:
166
+ it's what the platform's Host-based routing (Host header -> domain ->
167
+ `scope_id`, no membership check) resolves against, and what Caddy's
168
+ on-demand TLS `ask` endpoint checks before issuing a certificate. Tenant
169
+ isolation matters even more here than usual — an unknown or unverified
170
+ domain must never resolve to any scope, let alone a default one.
171
+
172
+ - **`src/types/index.ts`**: new `AppSiteDomain`/`AddAppSiteDomainInput`.
173
+ `BlogConfig` gains two new optional nested configs: `cdn?: { purgeUrl }`
174
+ (CDN cache-purge-by-URL, called from the new `after:publish` hook below)
175
+ and `domains?: { verificationSecret? }` (seeds the deterministic
176
+ verification token — see below). Both follow the exact "optional feature,
177
+ omit to skip" shape `BlogConfig.seo` already established in 2.6.0.
178
+ - **`src/repository/domains.ts`** (new): `createDomainRepository(adapter, scope)`
179
+ — scoped CRUD (`addDomain`/`listDomains`/`removeDomain`/`markVerified`/
180
+ `setPrimary`/`getPrimaryDomain`); first domain added to a scope becomes
181
+ primary automatically. Plus a deliberately UNSCOPED `findDomainByHost`
182
+ (by normalized/lower-cased domain, across every scope) — the primitive
183
+ both `resolveScopeByDomain` and the `ask` route resolve a bare Host header
184
+ against, since by definition neither yet knows which scope it belongs to.
185
+ `DomainNotFoundError`/`DomainAlreadyRegisteredError`.
186
+ - **`src/lib/domain-token.ts`** (new): `domainVerificationToken(scopeId, domain, secret?)`
187
+ — a DETERMINISTIC `sha256(scopeId:domain:secret)` token, not a column
188
+ stored on the row (keeps `AppSiteDomain`'s shape to exactly the ticket's
189
+ literal column list). `domainVerificationRecordName(domain)` names the
190
+ TXT record operators are asked to create: `_blogsketch-verify.{domain}`.
191
+ Falls back to a documented, exported, dev-only insecure secret
192
+ (`INSECURE_DEV_FALLBACK_SECRET`) when `BlogConfig.domains?.verificationSecret`
193
+ is omitted — real hosts should set a real secret.
194
+ - **`src/lib/domain-dns.ts`** (new): `verifyDomainTxtRecord(domain, expectedToken, resolveTxt?)`
195
+ — the real `dns.promises.resolveTxt` lookup at the TXT record name above,
196
+ with an injectable resolver (tests never hit real DNS). Never throws —
197
+ any DNS failure (NXDOMAIN, timeout, no records) resolves to `false`.
198
+ - **`src/service/domains.ts`** (new): `createDomainService(config)` — thin
199
+ per-request wrapper over `DomainRepository` (same `resolvePostScope`
200
+ scope-resolution convention as `createBylineService`), plus
201
+ `verifyDomain` (runs the real DNS check, marks verified on a match, never
202
+ throws on a failed *verification*, only on an unknown id) and
203
+ `verificationInstructions` (TXT/CNAME/A record values for the admin
204
+ screen). Also exports the standalone, UNSCOPED `resolveScopeByDomain(config, host)`
205
+ → `{ scopeId, verified } | null` — the platform's host-resolution cache's
206
+ DB-fallback primitive, and the `ask` route's own lookup. Returns `null`
207
+ for any unregistered host; never a default/fallback scope.
208
+ - **`src/service/cdn-hooks.ts`** (new): `registerCdnHooks(hooks, config, cdn)`
209
+ — a default `after:publish` handler, following `./seo-hooks.ts`'s EXACT
210
+ "optional feature wired through `BlogConfig` + the hook bus" pattern
211
+ (same double-registration `WeakSet` guard). Purges the newly-published
212
+ post's absolute URL via `cdn.purgeUrl`, skipped when the post isn't live
213
+ right now (same `isPostLive` gate `seo-hooks.ts` uses). Wired into
214
+ `createPostService` (`src/service/posts.ts`) alongside `registerSeoHooks`,
215
+ gated on `config.cdn`.
216
+ - **`src/next/domain-routes.ts`** (new): `createDomainAskRoute(config)` —
217
+ Caddy's on-demand TLS `ask` endpoint: a plain public GET (no auth, same
218
+ convention as `createBlogSearchRoute`/`createBlogFeedRoute`) that takes
219
+ `?domain=`, returns 200 only when that domain resolves to a VERIFIED
220
+ scope, 403 otherwise (including "never registered at all"). Exported from
221
+ the main `hazo_blog` entry, not `hazo_blog/next` (React-free, same
222
+ convention as every other route-handler factory).
223
+ - Everything above is re-exported from `src/index.ts`.
224
+ - Tests: `domains.repository.integration.test.ts` (CRUD + tenant isolation +
225
+ global uniqueness), `domains.service.test.ts` (DNS-mocked verify flow +
226
+ `resolveScopeByDomain`'s "unknown/unverified never resolves to a scope"
227
+ guarantee), `domain-token.test.ts`, `domain-dns.test.ts` (mocked
228
+ `resolveTxt`, never real DNS), `cdn-hooks.test.ts`, and
229
+ `routes.domain-ask.test.ts`. 32 new tests, all passing; full suite:
230
+ 461 passing / 52 failing (same 52 pre-existing failures as 2.7.1 — no
231
+ regressions).
232
+
233
+ ## 2.7.1 — 2026-11-06
234
+
235
+ **Security fix — cross-tenant category IDOR.** `createPost`/`updatePost`
236
+ stored a caller-supplied `category_id` with no scope check — a post in one
237
+ tenant could reference (and, via `listPostsForAdmin`, display) a category
238
+ belonging to a different tenant. `category_id` is now validated with a new
239
+ `assertCategoryVisible()`: a real category owned by another scope is
240
+ rejected (new `CategoryNotFoundError`); a dangling/never-existed id is still
241
+ accepted and resolves to "uncategorized", unchanged from 2.7.0's intended
242
+ soft-ref behaviour. `listPostsForAdmin`'s batch category/byline resolution
243
+ also gained a defense-in-depth scope filter. No DDL change.
244
+
245
+ ## 2.7.0 — 2026-11-06
246
+
247
+ **⚠️ DDL required — post list / primary category (Phase 1a issue 12).** Adds
248
+ a nullable `category_id TEXT` column to `hazo_blog_posts` (both dialects +
249
+ `migrations/`). No backfill needed (`NULL` = uncategorized, correct for
250
+ every pre-existing row). Postgres hosts need the idempotent
251
+ `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` statement (db_setup_postgres.sql)
252
+ applied to existing databases; SQLite hosts just re-run `db_setup_sqlite.sql`
253
+ (fresh `CREATE TABLE IF NOT EXISTS`), same upgrade story as the `status`
254
+ column added in 2.2.0.
255
+
256
+ Supports the platform's admin content-list screen (editors browsing/filtering
257
+ posts and pages) — new filters, a "primary category" field on `Post`, and a
258
+ dedicated admin-list query that resolves category + byline display data
259
+ without an N+1 query explosion.
260
+
261
+ - **`src/types/index.ts`**:
262
+ - `Post.category_id: string | null` (new) — a loose reference to
263
+ `hazo_blog_categories`, reusing the SAME legacy table the v1.6
264
+ `BlogPost.category_id` pointed at. Deliberate: the ticket only asked for
265
+ a single "primary category" and there's no Phase 1a ticket for a v2
266
+ category system, so this is a minimal addition rather than a new one.
267
+ Not a DB FK, matching every other optional/legacy cross-table reference
268
+ in this package. `CreatePostInput`/`UpdatePostInput` both gain an
269
+ optional `category_id`.
270
+ - `ListPostsQuery` gains `categoryId?: string`, `bylineId?: string`, and
271
+ `hasPendingChanges?: boolean` (SQL-level — `live_revision_id IS NOT
272
+ NULL AND draft_revision_id IS NOT NULL`, so pagination/totals stay
273
+ correct, unlike a post-hoc JS filter using the existing
274
+ `hasPendingChanges()` helper from `src/lib/revisions.ts`). `sortBy` gains
275
+ `"title"`.
276
+ - New `PostListItem` (extends `Post` with `category: { id, name, slug } |
277
+ null` and `bylines: Byline[]`) and `PaginatedPostListResult` —
278
+ denormalized display types for the admin list screen, kept separate
279
+ from `Post`/`PaginatedPostsResult` so ordinary callers (e.g. the issue
280
+ 11 sitemap source) don't carry a join they don't need.
281
+ - **`src/repository/posts.ts`**:
282
+ - `listPosts` applies `categoryId` (`category_id = ?`), `hasPendingChanges`
283
+ (two `IS NOT NULL` clauses), and `bylineId` (one extra query against
284
+ `hazo_blog_post_bylines` for matching `post_id`s, then an `id IN (...)`
285
+ clause — same pattern `listPostsByByline` in `../repository/bylines.ts`
286
+ already uses the other direction; NOT a SQL join).
287
+ - New `listPostsForAdmin(query?)`: calls `listPosts` for the filtered/
288
+ paginated page, then batch-resolves `category_id` → `{id, name, slug}`
289
+ and this page's bylines (ordered by position) with one extra query
290
+ each — never a per-row query, never a SQL join. A join was considered
291
+ and rejected: this package has no existing join-query precedent,
292
+ bylines alone would need a 3-table join to preserve ordering, and
293
+ `category_id` is a loose/legacy reference that may not resolve at all.
294
+ Own `createCrudService` instances for `hazo_blog_categories`/
295
+ `hazo_blog_bylines`/`hazo_blog_post_bylines` (duplicated rather than
296
+ importing `../repository/bylines.ts`, same "stay independently
297
+ testable" convention that module's `PostNotFoundForBylineError`
298
+ doc-comment already documents).
299
+ - **`src/service/posts.ts`** — `PostService.listPostsForAdmin(query?, req?)`,
300
+ a thin delegate to the repository method, same shape as every other
301
+ `PostService` method.
302
+ - **DDL** — see the `⚠️ DDL required` note above.
303
+ - **Tests** — `src/__tests__/posts.repository.integration.test.ts`: new
304
+ `category_id` round-trip coverage, `categoryId`/`bylineId`/
305
+ `hasPendingChanges` filter coverage (including that `bylineId` with no
306
+ matches returns a well-formed empty page, and that `hasPendingChanges`
307
+ affects pagination totals, not just the returned rows), and
308
+ `listPostsForAdmin` coverage (category/byline resolution, a dangling
309
+ `category_id` resolving to `null`, filter pass-through, tenant
310
+ isolation).
311
+
312
+ **No route-factory changes.** Following the pattern the platform app
313
+ already established for hazo_seo in Phase 1a issue 11 (calling service
314
+ functions directly from its own `/app/api/*` routes rather than mounting a
315
+ package route factory), the platform's admin content-list screen calls
316
+ `createPostService(buildBlogConfig(scopeId)).listPostsForAdmin(...)`
317
+ directly — `src/next/post-routes.ts`'s `createPostManageRoutes` (API-key
318
+ auth) is for a different consumer (programmatic/management API) and is
319
+ unchanged by this ticket.
320
+
321
+ ## 2.6.0 — 2026-11-06
322
+
323
+ SEO admin screens, hazo_blog portion (Phase 1a issue 11). Adds a
324
+ `previousSlug` hook field, a hazo_seo-shaped sitemap source for the v2 `Post`
325
+ model, and optional default SEO automation hooks (auto-301 redirects +
326
+ IndexNow submission) wired through a new `BlogConfig.seo` field. hazo_seo is
327
+ a NEW optional peer dependency (`^0.19.0`) — nothing in this release requires
328
+ it to be installed.
329
+
330
+ - **`src/lib/hook-bus.ts`** — `AfterSaveHookPayload` gains `previousSlug:
331
+ string | null`: the post's slug immediately before this save, `null` on
332
+ `action: 'create'`. Lets an `after:save` handler detect a slug change
333
+ without re-fetching the pre-save row. `src/service/posts.ts`'s
334
+ `saveWithHooks` now passes `current?.slug ?? null` through on every
335
+ `emitAfter("after:save", ...)` call. Purely additive to an existing
336
+ payload interface — safe for any existing handler that destructures only
337
+ the fields it already used.
338
+ - **`src/lib/status.ts`** — new `isPostLive(post: Post): boolean`
339
+ (`status === 'published' && deleted_at === null`), the v2 model's
340
+ equivalent of the legacy `BlogPost` model's "live-post gate" invariant.
341
+ For the v2 `ContentStatus` lifecycle this is a single check with no date
342
+ math: the `'scheduled' -> 'published'` transition itself is only performed
343
+ once `publish_at` has passed (the auto-publish maintenance job), so
344
+ `status === 'published'` already means "live right now". Shared by both
345
+ new features below so they agree on what "live" means. Re-exported from
346
+ `./lib/index.js`.
347
+ - **`src/seo/hazo-seo-sitemap-source.ts`** (new) — `createBlogSitemapSource
348
+ (service: PostService, config: ResolvedBlogConfig, opts?: { name?: string;
349
+ postType?: string; req?: Request }): SitemapSource` (hazo_seo's
350
+ `SitemapSource`/`SitemapEntry`, type-only imported — zero runtime
351
+ dependency just to use the types). A SEPARATE, NEW function from the
352
+ existing `getBlogSitemapEntries` (legacy `BlogPostWithRelations` model,
353
+ untouched) — does not import from or modify it. Lists live posts via
354
+ `PostService.listPosts({ status: 'published', ... })`, optionally filtered
355
+ by `opts.postType` (one sitemap SOURCE per content type, e.g.
356
+ `'blog-posts'`/`'blog-pages'`, is a host choice this function supports but
357
+ doesn't require). `lastModified` is the LIVE revision's `created_at` when
358
+ `post.live_revision_id` resolves, falling back to `post.updated_at` only
359
+ when it can't — a draft edit bumps `updated_at` without moving what's
360
+ actually public, so the live revision's own timestamp is the only
361
+ truthful value (matches hazo_seo's truthful-lastmod-guard philosophy).
362
+ `images: [post.og_image]` when set. Never sets `alternates` — the v2
363
+ `Post` model has no translation-cluster/grouping field to derive hreflang
364
+ from (documented gap, not a bug). `opts.req` is new/notable: hazo_seo's
365
+ `SitemapSource.fetch(since?)` signature has no request parameter, but
366
+ `PostService` needs one whenever `BlogConfig.resolveScope` is configured
367
+ (`resolvePostScope` throws `ScopeUnresolvedError` otherwise) — a
368
+ multi-site host threads a fixed `Request` here (pinning the bound service
369
+ to one site) via closure; add-on/single-site hosts (no `resolveScope`
370
+ configured) can omit it. No "list every scope" primitive exists anywhere
371
+ in this package — a sitemap-per-site host builds one `PostService`/one
372
+ `createBlogSitemapSource` call per site. Re-exported from
373
+ `src/seo/index.ts` (so it's reachable via both the `hazo_blog/seo` subpath
374
+ and the main `hazo_blog` barrel) and from `src/index.ts`.
375
+ - **`BlogConfig.seo`** (new, optional; `src/types/index.ts`) —
376
+ `{ redirectsService?: { createRedirect(input: { fromPath; toPath;
377
+ statusCode?; scopeId? }): Promise<unknown> }; indexNow?: { key; host;
378
+ endpoint?; dryRun?; fetchImpl? } }`. `redirectsService` is a narrow
379
+ duck-typed shape (not hazo_seo's concrete type) so the field type-checks
380
+ with hazo_seo absent; hazo_seo's real `createRedirectService(adapter)`
381
+ return value satisfies it as-is.
382
+ - **`src/service/seo-hooks.ts`** (new) — `registerSeoHooks(hooks: HookBus,
383
+ config: ResolvedBlogConfig, seo: NonNullable<BlogConfig['seo']>): void`,
384
+ wired once from `createPostService(config)` (right after its `hooks`
385
+ variable is built, via `resolveConfig(config)`) when `config.seo` is
386
+ present:
387
+ - `after:save` — on `action: 'update'` where `previousSlug` differs from
388
+ the new slug AND the post is live (`isPostLive`) AND
389
+ `seo.redirectsService` is configured, creates a 301 redirect from the
390
+ old root-relative path to the new one (derived by stripping
391
+ `config.baseUrl` off `postUrl(...)` — there's no path-only variant
392
+ exported). A slug change on a draft/unpublished post deliberately
393
+ creates no redirect (nothing was ever public at the old slug). The
394
+ `createRedirect` call is NOT wrapped in try/catch — hazo_seo's
395
+ `from_path` is a GLOBAL primary key across every scope/site (inherited
396
+ limitation, explicitly not fixed here), so two tenants renaming into the
397
+ same old slug can collide, and that collision's synchronous `Error`
398
+ degrades to "no redirect created, logged" via `HookBus.emitAfter`'s
399
+ existing catch — never a broken save.
400
+ - `after:publish` — when `seo.indexNow` is configured, resolves
401
+ `hazo_seo/files` lazily via `optional_import` (same convention as
402
+ `resolvePostScope`'s `hazo_auth/server-lib` lookup) and calls
403
+ `submitToIndexNow([postUrl(...)], seo.indexNow)`; no-ops silently when
404
+ hazo_seo isn't installed.
405
+ - Double-registration guard: a module-level `WeakSet<HookBus>` makes a
406
+ second `registerSeoHooks` call against the SAME `hooks` instance a
407
+ no-op, so `createPostService` being constructed more than once against
408
+ one shared `config.hooks` bus can't double-fire either handler.
409
+ Re-exported from `src/index.ts`.
410
+ - **`package.json`** — `hazo_seo: "^0.19.0"` added to `peerDependencies` +
411
+ `peerDependenciesMeta` (`optional: true`), mirroring `hazo_auth`'s exact
412
+ treatment. NOT added to `devDependencies` — `hazo_auth`/`hazo_images`/
413
+ `hazo_jobs`/`hazo_theme`/`hazo_i18n`/`hazo_llm_api` (the package's other
414
+ optional peers) aren't there either, so hazo_seo follows the same
415
+ established pattern.
416
+ - **Tests** (4 new files) — `seo-hooks.test.ts` (real SQLite: auto-301 fires
417
+ exactly when published + slug changed + `redirectsService` configured;
418
+ does not fire for a draft slug change, an unchanged slug, create, or a
419
+ missing `redirectsService`; a `createRedirect` rejection simulating
420
+ hazo_seo's `from_path` collision doesn't throw out of `updatePost`;
421
+ IndexNow submission on publish with the right single URL; double-
422
+ registration guard); `seo-hooks.indexnow-unresolvable.test.ts` (hazo_seo
423
+ unresolvable — mocks `hazo_seo/files`'s import to reject outright — the
424
+ `after:publish` handler no-ops instead of throwing out of `setStatus(...,
425
+ 'published')`); `hazo-seo-sitemap-source.test.ts` (entry shape, truthful
426
+ lastmod vs. fallback, image inclusion, draft/unpublished exclusion,
427
+ `postType` filtering, the `opts.req`/`ScopeUnresolvedError` interaction for
428
+ both multi-site and add-on-mode hosts); plus 3 new cases in
429
+ `posts.service.hooks.test.ts` for `previousSlug` (null on create, carries
430
+ the old slug on update, equals the current slug when unchanged).
431
+
432
+ ## 2.5.0 — 2026-11-06
433
+
434
+ ⚠️ DDL required — new `hazo_blog_media`/`hazo_blog_media_usage` tables.
435
+
436
+ End-to-end media pipeline data layer (Phase 1a issue 10): upload → process
437
+ (variants/blurhash/hash via hazo_images) → store (host-supplied
438
+ `hazo_files`-shaped storage provider) → dedup by content hash → persist →
439
+ track where each media item is used. This is NEW v2 infrastructure for the
440
+ `Post`/content model (tickets 04/06–09) — completely separate from, and
441
+ does NOT touch, the legacy v1.6 `BlogMediaConfig`/`createBlogImageUploadRoute`
442
+ single-image-upload path (`src/next/image-upload.ts`), which stays wired to
443
+ `BlogConfig.media`.
444
+
445
+ - **DDL** — `hazo_blog_media` (one row per distinct uploaded file per
446
+ tenant: filename/mime_type/size_bytes/width/height/hash/blurhash/
447
+ dominant_colour/focal_point/alt/caption/tags/ai_data/variants/storage_key)
448
+ and `hazo_blog_media_usage` (surrogate `id` PK — same "CrudService only
449
+ deletes by a single PK" reasoning as `hazo_blog_post_tags`/
450
+ `hazo_blog_post_bylines` — tracking `(media_id, entity_type, entity_id,
451
+ field, scope_id)`). `idx_hazo_blog_media_scope_hash` is a UNIQUE index on
452
+ `(scope_id, hash)` — dedup is per-tenant, never global: the same bytes
453
+ uploaded to two different sites get two separate rows. Both dialects +
454
+ `migrations/` mirror updated.
455
+ - **`src/repository/media.ts`** (new) — `createMediaRepository(adapter, scope)`:
456
+ plain CRUD (`listMedia` with in-app filtering for search/source/date/
457
+ orientation/used-unused — this package's QueryBuilder has no portable
458
+ JSON/substring-OR/date-range operators rich enough, same convention as
459
+ `listPosts`/`searchPosts`), `getMediaByHash` (the dedup lookup),
460
+ `insertMedia`, `updateMedia`, `removeMedia` (explicit usage-row cleanup
461
+ rather than relying on the DDL's `ON DELETE CASCADE` — confirmed
462
+ empirically that `hazo_connect`'s `CrudService.deleteById` does not
463
+ reliably trigger it, same reasoning as `../repository/index.ts`'s
464
+ `deletePost` cleaning up `hazo_blog_post_tags` itself), `listUsageForMedia`,
465
+ `setUsage` (idempotent per `(entity_type, entity_id, field)`).
466
+ - **`src/service/media.ts`** (new) — `createMediaService(config, deps?)`'s
467
+ `uploadMedia(buffer, filename, mimeType, req?)` orchestrates: block SVG
468
+ (mime `image/svg+xml` or a `.svg` filename extension) → fire
469
+ `media:before:upload` (vetoable — the hook bus's Phase 1a issue 09 stub,
470
+ now actually fired for the first time) → `processMediaVariants` (dynamic
471
+ `import("hazo_images/server")`, injectable via `deps.processMediaVariants`
472
+ for tests — a SEPARATE seam from `../next/image-upload.ts`'s
473
+ `deps.loadImages`, same convention, don't merge them) → dedup by
474
+ `(scope_id, hash)` (a hit returns the existing row UNCHANGED, skipping
475
+ storage entirely) → store every variant via the host-supplied
476
+ `BlogFileStorageProvider` → insert the `hazo_blog_media` row → fire
477
+ `media:after:upload` (fire-and-forget). Also: `removeMedia` (best-effort
478
+ storage cleanup, logged not thrown, then the DB delete),
479
+ `syncMediaUsageForEntity` (tolerant — catches and logs via `hazo_logs`,
480
+ never throws, so a usage-tracking issue can never break a content save),
481
+ and `toAdapter(req?)` returning a plain object literally shaped like
482
+ `MediaSource` (the adapter hazo_images/ui's `MediaGallery`/`MediaPicker`
483
+ expect, structurally — see below).
484
+ - **New `BlogConfig.mediaStorage` field** (`src/types/index.ts`) —
485
+ `{ getStorageProvider: (scope) => BlogFileStorageProvider | Promise<...> }`.
486
+ Named `mediaStorage` rather than `media2`/`mediaV2` to read naturally at
487
+ call sites while staying unambiguous next to the EXISTING, UNTOUCHED
488
+ `media` field (`BlogMediaConfig`, the legacy v1.6 config). The host is
489
+ expected to hand back an `hazo_files` `S3Provider` instance (or any
490
+ duck-typed equivalent) already constructed with `key_prefix` set to that
491
+ tenant's scope id — THAT is what satisfies the acceptance criteria's
492
+ "storage keys prefixed by scope_id": `uploadMedia` itself never prepends
493
+ `scope_id` to a path, it trusts the provider instance it's handed.
494
+ `BlogFileStorageProvider` is a new duck-typed interface (`put`/
495
+ `getSignedUrl`/`delete` — the subset of `hazo_files`' `FileStorageProvider`
496
+ this package actually calls), same "no compile-time `hazo_files` import"
497
+ convention as the existing `BlogFileManager`.
498
+ - **New `Media`/`MediaUsage`/`MediaSource` types** (`src/types/index.ts`) —
499
+ `MediaSource` is the adapter interface (`list`/`get`/`upload`/`update`/
500
+ `remove`, plus `listUsage` for the gallery's "where used" list, plus
501
+ `generate`/`regenerate` as genuinely-unimplemented optional Phase 3
502
+ stubs). `Media.variants` maps a `"{width}-{format}"` key to that variant's
503
+ `storage_key`; `Media.storage_key` (top-level) is the SAME value as the
504
+ widest-width AVIF entry — the "primary" variant representing this item at
505
+ a glance.
506
+ - **`src/lib/hook-bus.ts`'s `media:before:upload`/`media:after:upload`** —
507
+ defined (unfired) since Phase 1a issue 09; THIS release is the one that
508
+ actually fires them, from `uploadMedia`. Payload shapes are unchanged.
509
+ - **`src/service/posts.ts`** — `createPost`/`updatePost` (via the shared
510
+ `saveWithHooks` helper) now call a new internal `syncPostMediaUsage` after
511
+ every successful save, pointing `('post', post.id, 'og_image')` at
512
+ `post.og_image` (the only media-referencing field the v2 `Post` model has
513
+ today). Builds its own `MediaRepository` straight from
514
+ `../repository/media.js` — deliberately NOT `../service/media.js`'s
515
+ `createMediaService`, to avoid a circular import between the two service
516
+ modules — and uses `post.scope_id` directly rather than re-resolving
517
+ scope. Tolerant by design: every error is caught and logged via
518
+ `hazo_logs`, never thrown, so a usage-tracking issue can never break an
519
+ otherwise-successful post save. Awaited (not fire-and-forget) specifically
520
+ so the write isn't lost under a serverless host that returns immediately
521
+ after the save.
522
+ - **`src/next-shims.d.ts`** — added a loose `processMediaVariants` ambient
523
+ declaration to the existing `hazo_images/server` shim (same "optional
524
+ peer, type-checks standalone without it installed" reasoning as the
525
+ shim's pre-existing `createImageProcessHandler` entry).
526
+ - **Tests** (3 new files) — `media.repository.integration.test.ts` (real
527
+ SQLite: CRUD, dedup is per-scope not global, usage set/update/clear,
528
+ cascade-on-remove, every `listMedia` filter, cross-scope isolation);
529
+ `media.service.test.ts` (fake `processMediaVariants` + fake
530
+ `BlogFileStorageProvider`: full upload orchestration, dedup skips storage
531
+ entirely, SVG rejection by MIME and by extension, hook veto blocks
532
+ upload, `media:after:upload` fires with the right path/url, missing
533
+ `mediaStorage` config throws, same-hash-different-scope uploads don't
534
+ collide, `removeMedia`, `toAdapter`); `posts.service.media-usage.test.ts`
535
+ (real SQLite: usage row created/updated/cleared across
536
+ create/update, and a post save still succeeds even when usage-sync itself
537
+ errors — `hazo_logs` mocked via `jest.unstable_mockModule`, same pattern
538
+ as `hook-bus.test.ts`, because this last case deliberately exercises the
539
+ real error-logging branch).
540
+
541
+ ## 2.4.0 — 2026-11-06
542
+
543
+ Typed hook bus for content-lifecycle events (Phase 1a issue 09). Purely
544
+ additive — a new standalone module plus a new optional `BlogConfig` field,
545
+ wired into the v2 `Post`/`hazo_blog_posts` CRUD flow
546
+ (`src/repository/posts.ts`/`src/service/posts.ts`). Not DDL, no DB change.
547
+ Unrelated to the legacy v1.6 `BlogConfig.onPostSaved?: (post, action) => void`
548
+ callback (still used, untouched, by `src/service/index.ts`'s
549
+ `createTranslation`/`translatePost`/upsert flow for the old `BlogPost` shape)
550
+ — don't confuse the two.
551
+
552
+ - **`src/lib/hook-bus.ts`** (new) — `createHookBus()` factory (matching this
553
+ package's `createPostRepository`/`createPostService` factory-function
554
+ convention) returning a `HookBus`: `on<K>(event, handler)` (TypeScript
555
+ generic infers the handler's payload type from the event name `K`, via the
556
+ new `BlogHookEventMap` covering all 10 events from the acceptance
557
+ criteria — `before:save`/`after:save`, `before:publish`/`after:publish`,
558
+ `before:delete`/`after:delete`, plus four forward-looking stubs, see
559
+ below), returning an unsubscribe function; `emitBefore<K>` (runs EVERY
560
+ registered `before:`-ish handler sequentially to completion — never
561
+ short-circuits on the first veto, per the literal acceptance-criteria text
562
+ — and reports `{ vetoed: true, reason }` using the first vetoing handler's
563
+ reason if any vetoed); `emitAfter<K>` (runs every handler, each one
564
+ individually try/caught so one throwing/rejecting handler never stops the
565
+ next, and never itself rejects — any caught error is logged via
566
+ `hazo_logs` instead of thrown). A `before:`/`after:` handler's `HookResult`
567
+ is `void | { veto: true; reason: string }`.
568
+ - **`media:before:upload`/`media:after:upload`** (ticket 10 — no upload
569
+ implementation exists yet), **`cron`** (likely ticket 16), and
570
+ **`page:metadata`** (likely ticket 11's SEO work) are defined as
571
+ emittable payload types in `BlogHookEventMap` now, per the acceptance
572
+ criteria, but nothing fires them yet — stubs for those future tickets to
573
+ wire up.
574
+ - `hazo_logs`' real `createLogger()` is constructed LAZILY, on the first
575
+ actual after-handler error, not at module-load time — its background
576
+ "upgrade to the real winston logger" dynamic import is otherwise a
577
+ dangling async operation that can crash an unrelated Jest test file
578
+ under `--experimental-vm-modules` (a real issue hit while writing this
579
+ ticket's tests, not a hypothetical).
580
+ - **`src/types/index.ts`** — new optional `BlogConfig.hooks?: HookBus`
581
+ field. Deliberately NOT a package-level singleton: omitted, `createPostService`
582
+ builds a fresh PRIVATE `createHookBus()` internally (nothing outside that
583
+ closure can reach it, so there's nothing to share). A host that actually
584
+ wants to register handlers must build its own bus with this package's
585
+ exported `createHookBus()` and pass the SAME instance into every
586
+ `createPostService(config)` call it should be shared across — see the
587
+ field's own doc-comment for the full reasoning (mirrors `getHazoConnect`'s
588
+ "host builds it, package consumes it" shape, not `resolveScope`'s
589
+ "package calls a host function per request" shape, since registered
590
+ handlers need to persist across requests).
591
+ - **`src/service/posts.ts`** — `createPost`/`updatePost` now fire
592
+ `before:save`/`after:save`; `publishDraft` and `setStatus(..., 'published')`
593
+ fire `before:publish`/`after:publish`; `trashPost`, the void-returning
594
+ `deletePost` back-compat alias, and `setStatus(..., 'trashed')` all fire
595
+ `before:delete`/`after:delete` (routed through one shared internal helper
596
+ so the hooks can't drift between the three call sites). A veto from any
597
+ `before:` handler throws the new `HookVetoError` (`{ event, reason }`),
598
+ aborting the operation before the repository call runs — surfaced
599
+ verbatim to the caller. `after:` hooks are fired but NOT awaited by these
600
+ methods (fire-and-forget, per `HookBus.emitAfter`'s contract) — the
601
+ methods still return as soon as the underlying write commits.
602
+ Deliberately NOT wired into the cross-tenant `purgeTrashedPosts()`
603
+ maintenance job (`src/repository/posts.ts`) — see that function's own
604
+ doc-comment for why a batch hard-delete across every tenant, with no
605
+ `BlogConfig`/hook-bus instance to fire against, doesn't get per-post
606
+ `before:delete`/`after:delete` hooks.
607
+ - **Tests** — `src/__tests__/hook-bus.test.ts` (new, 12 cases): the bus in
608
+ isolation — registration/unsubscribe, veto reporting, "every before:
609
+ handler runs even after one vetoes", async/sequential handler ordering,
610
+ after-handler error isolation + `hazo_logs` logging (`hazo_logs` mocked
611
+ via `jest.unstable_mockModule`, same ESM-mocking convention as
612
+ `service.translate-post.test.ts`'s `hazo_llm_api/server` mock), and a
613
+ compile-time proof of the event→payload generic inference (`@ts-expect-error`
614
+ cases — this package has no `tsd`/type-testing setup, so this stays a
615
+ plain `.test.ts` file per Phase 1a issue 09's own guidance).
616
+ `src/__tests__/posts.service.hooks.test.ts` (new, 14 cases, real-SQLite
617
+ integration — same harness as `posts.service.test.ts`): veto actually
618
+ blocking `createPost`/`updatePost`/`publishDraft`/`setStatus`/`trashPost`/
619
+ `deletePost` (not just the bus in isolation), multiple handlers all
620
+ running, `after:` firing post-commit, and the `config.hooks` host-supplied-bus
621
+ wiring path. 359 passing vs. 333 on the prior HEAD (net +26), the same 52
622
+ pre-existing failures (v1.6/v2.0.0 schema split, unrelated) unchanged, 0
623
+ new failures.
624
+ - **`package.json`** — adds `hazo_logs` (`^2.3.1`) as a regular `dependency`
625
+ (not a peer — matches `hazo_logs`' own README: logging-only consumption
626
+ needs no peer deps, only its optional log-viewer UI does, and this
627
+ package's own peers already cover `react`/`next` for anyone who'd also
628
+ want that UI).
629
+
630
+ ## 2.3.0 — 2026-11-06
631
+
632
+ **⚠️ DDL required — bylines (Phase 1a issue 08).** Adds two new tables (both
633
+ dialects + `migrations/`): `hazo_blog_bylines` and `hazo_blog_post_bylines`.
634
+ Purely additive — no existing table is touched, so there's nothing to
635
+ backfill; hosts just re-apply `db_setup_sqlite.sql`/`db_setup_postgres.sql`.
636
+
637
+ Author attribution independent of `hazo_auth` login accounts: a byline is a
638
+ named author (real user, guest, or imported WordPress author) that can be
639
+ assigned to one or more posts, ordered for display. Entirely new — doesn't
640
+ touch `src/repository/posts.ts`/`src/service/posts.ts` or any status/revision
641
+ machinery from tickets 04/06/07, and is unrelated to the legacy v1.6
642
+ `author_id`/`AuthorInfo` fields on the untouched `BlogPost` shape.
643
+
644
+ - **`db_setup_sqlite.sql` / `db_setup_postgres.sql`** — `hazo_blog_bylines`
645
+ (`id`, `scope_id` NOT NULL, `name`, `slug`, `bio`, `avatar_url`, `user_id`,
646
+ `created_at`, `updated_at`), unique index on `(scope_id, slug)` mirroring
647
+ `hazo_blog_posts`' own `(scope_id, slug, locale)` pattern. `user_id` is a
648
+ loose, nullable `TEXT` column — deliberately NOT a DB `REFERENCES` into a
649
+ `hazo_auth` users table, since `hazo_auth` is an OPTIONAL peer this
650
+ package's own migrations don't own; validated at the application layer
651
+ only if/when `hazo_auth` is present.
652
+ `hazo_blog_post_bylines` (post↔byline join: `id`, `post_id`, `byline_id`,
653
+ `position`) has a **surrogate `id` PK**, not a composite
654
+ `(post_id, byline_id)` key — same reasoning as `hazo_blog_post_tags`:
655
+ `hazo_connect`'s `CrudService` only deletes by a single PK. Unique index
656
+ on `(post_id, byline_id)` prevents duplicate assignment; an index on
657
+ `byline_id` and on `(post_id, position)` back the two list queries below.
658
+ - **`src/types/index.ts`** — `Byline`, `PostByline` (a `Byline` resolved with
659
+ the junction row's own id + `position`), `CreateBylineInput`,
660
+ `UpdateBylineInput`.
661
+ - **`src/repository/bylines.ts`** (new) — `createBylineRepository(adapter, scope)`,
662
+ a sibling of `createPostRepository`: scoped CRUD for bylines
663
+ (`createByline`/`updateByline`/`getBylineById`/`listBylines`/`deleteByline`),
664
+ plus `assignBylineToPost`/`removeBylineFromPost`/`reorderPostBylines`/
665
+ `listBylinesForPost`/`listPostsByByline` (paginated, same shape as
666
+ `PostRepository.listPosts`). `assignBylineToPost`/`removeBylineFromPost`/
667
+ `reorderPostBylines` all resolve the post within this repository's own
668
+ scope first — the junction table itself carries no `scope_id`, so tenant
669
+ isolation on assignment is enforced here, not by a DB constraint. New
670
+ error classes `BylineNotFoundError` / `PostNotFoundForBylineError` (the
671
+ latter deliberately distinct from `../repository/posts.ts`'s own
672
+ `PostNotFoundError`, duplicated rather than imported so this module stays
673
+ independently testable — see its doc-comment).
674
+ - **`src/service/bylines.ts`** (new) — `createBylineService(config)`, a thin
675
+ `req`-scoped wrapper matching `PostService`'s shape exactly. Deliberately
676
+ **reuses** `resolvePostScope` from `../service/posts.ts` rather than
677
+ duplicating it — bylines share posts' exact scope-resolution rules
678
+ (add-on-mode default to `hazo_auth`'s system scope, `ScopeUnresolvedError`
679
+ on an unresolved multi-site request).
680
+ - **`src/next/byline-routes.ts`** (new) — six route factories following
681
+ `../next/post-routes.ts`'s shape (`blog:read`/`blog:write` `hazo_api`
682
+ scopes, `ok()`/`fail()` envelopes): `createBylineManageRoutes` (list/create),
683
+ `createBylineItemRoutes` (get/update/delete), `createBylinePostsRoutes`
684
+ (list posts by byline), `createPostBylinesRoutes` (list a post's bylines /
685
+ assign), `createPostBylineItemRoutes` (remove one assignment),
686
+ `createPostBylinesReorderRoutes` (set display order) — the last two
687
+ mounted on their own sub-resources, same reasoning as
688
+ `createPostCompareRoutes`/`createPostStatusRoutes` in `post-routes.ts`.
689
+ - **Tests**: `src/__tests__/bylines.repository.integration.test.ts` (28
690
+ cases — CRUD, assignment + ordering including re-assign-updates-position
691
+ and ignore-unassigned-ids-on-reorder, `listPostsByByline`, and tenant
692
+ isolation across every mutating method including the "byline from scope A
693
+ can't be assigned to a post in scope B" case) and
694
+ `src/__tests__/routes.bylines.test.ts` (6 cases covering the full route
695
+ surface end to end). 52 pre-existing failures (v1.6/v2.0.0 schema split,
696
+ unrelated to this change) are unchanged; 333 passing vs. 299 on the prior
697
+ HEAD (net +34), 0 new failures.
698
+
699
+ Bumps 2.2.0 -> 2.3.0 (minor, new required DDL).
700
+
701
+ ## 2.2.0 — 2026-11-06
702
+
703
+ **⚠️ DDL required — status lifecycle + trash (Phase 1a issue 07).** Adds a
704
+ `status` column to `hazo_blog_posts` (both dialects + `migrations/`):
705
+ `TEXT NOT NULL DEFAULT 'draft'`, backfilling every pre-2.2.0 row to
706
+ `'draft'` so nothing breaks on upgrade. Postgres hosts also need the
707
+ idempotent `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` statement
708
+ (db_setup_postgres.sql) applied to existing databases; SQLite hosts just
709
+ re-run `db_setup_sqlite.sql` (fresh `CREATE TABLE IF NOT EXISTS`, same as
710
+ every other column added to this table so far).
711
+
712
+ The full v2 content lifecycle: six statuses (`draft`/`in_review`/`queued`/
713
+ `scheduled`/`published`/`trashed`) forming a pipeline with optional steps,
714
+ validated transitions, soft-delete trash with restore and a 30-day
715
+ auto-purge, and a `publish_at`-driven auto-publish worker — all wired
716
+ through `hazo_jobs`' `MaintenanceJob` primitive (its first usage in this
717
+ package).
718
+
719
+ - **`src/types/index.ts`** — new `ContentStatus` type (6-value union),
720
+ deliberately NOT the legacy `PostStatus` (3 values, backs the untouched
721
+ v1.6 `BlogPost`/`status`) — a new type name avoids any confusion between
722
+ the two coexisting shapes. `Post.status: ContentStatus`, defaulting to
723
+ `'draft'`. `ListPostsQuery.status` filter added alongside the existing
724
+ `type` filter.
725
+ - **`src/lib/status.ts`** (new) — `isValidStatusTransition(from, to)`: a
726
+ pure, DB-free state machine enforcing the pipeline (draft → in_review →
727
+ queued → scheduled → published, review and queue both skippable, draft →
728
+ published valid), any-status → trashed, and trashed → draft (restore,
729
+ the ONLY legal exit from trashed — never a direct restore to published).
730
+ `CONTENT_STATUSES` is the ordered list of all six values. Re-exported
731
+ from `./lib`; also the one new import `./ui/content-status-badge.tsx`
732
+ needed (see below), since `../lib/*` is within the `./ui` export
733
+ boundary's allowed-import list.
734
+ - **`src/repository/posts.ts`**:
735
+ - `setStatus(id, to)` — validates the transition, throws the new
736
+ `PostInvalidStatusTransitionError` on an invalid edge. Transitioning TO
737
+ `'published'` also promotes a staged draft to live in the same update
738
+ (same `draft_revision_id → live_revision_id` + `_rev` bump +
739
+ first-publish-only `published_at` stamp `publishDraft` does — issue 06's
740
+ `publishDraft` and this issue's status machine are related but
741
+ distinct: `publishDraft` promotes a staged revision and never touches
742
+ `status`; `setStatus(..., 'published')` governs the lifecycle stage
743
+ and, as a documented side effect, performs the same promotion IF
744
+ something's staged — but, unlike calling `publishDraft` directly, never
745
+ throws `PostNoDraftRevisionError` when nothing is). Transitioning TO
746
+ `'trashed'` stamps `deleted_at`; `'trashed' -> 'draft'` clears it.
747
+ - `trashPost(id)` / `restorePost(id)` — thin `setStatus` wrappers.
748
+ `trashPost` **supersedes** `softDeletePost`: `softDeletePost` is kept
749
+ only as a void-returning back-compat alias that now delegates to
750
+ `trashPost`, so every existing caller also gets a correct `status`
751
+ (previously it stamped `deleted_at` only, with no `status` column to
752
+ set).
753
+ - `findDueScheduledPosts(adapter)` / `purgeTrashedPosts(adapter, days?)`
754
+ (new, module-level exports, NOT part of `PostRepository`) — cross-tenant
755
+ maintenance queries: a scheduled-publish or trash-purge sweep runs
756
+ across every `scope_id` in one pass, unlike every other method on this
757
+ file's single-scope `PostRepository`. `purgeTrashedPosts` hard-deletes;
758
+ `hazo_blog_revisions` rows cascade via the existing `ON DELETE CASCADE`
759
+ FK, no separate cleanup needed.
760
+ - **`src/jobs/maintenance-jobs.ts`** (new) — `hazo_jobs`' `MaintenanceJob`
761
+ primitive (its README's "Maintenance jobs", v0.10+) wired up for the
762
+ first time in this package:
763
+ - `createAutoPublishScheduledPostsJob` — every 5 minutes, promotes due
764
+ `'scheduled'` posts to `'published'` via `setStatus`, across every
765
+ tenant; a failure on one post is logged and skipped, not fatal to the
766
+ sweep.
767
+ - `createPurgeTrashedPostsJob` — daily at 03:00 UTC, hard-deletes
768
+ `'trashed'` posts older than `payload.retainDays` (default 30).
769
+ - `registerBlogMaintenanceJobs(config, options?, deps?)` — wires both
770
+ into one `hazo_jobs` worker via `register_maintenance_jobs`. Returns
771
+ `null` when `hazo_jobs` isn't installed (dynamic `import()`, same
772
+ degrade-gracefully shape as `createBlogImageUploadRoute`'s
773
+ `loadImages` seam) — meant to be called once from the host's own
774
+ long-running worker process, not a Next.js request handler.
775
+ - **Integration nuance surfaced by actually type-checking against
776
+ hazo_jobs' real `.d.ts`** (hazo_jobs happens to be resolvable in this
777
+ monorepo workspace, confirmed, not assumed): `createJobsClient` needs a
778
+ raw-SQL `{ raw(sql, values): Promise<rows[]> }` adapter — a lower-level,
779
+ different contract than the `hazo_connect` adapter
780
+ `config.getHazoConnect()` returns. `hazo_connect`'s Postgres adapter
781
+ happens to also expose a working `.raw()` (see `maintainSearchVector`),
782
+ so Postgres hosts can omit the new `options.rawAdapter` entirely;
783
+ `hazo_connect`'s SQLite adapter does not (only `.rawQuery`, a different
784
+ shape), so a SQLite/add-on-mode host must pass its own raw wrapper —
785
+ hazo_jobs' own README SQLite quick-start shows exactly this recipe over
786
+ `better-sqlite3`. hazo_jobs' `MaintenanceJob` primitive itself is fully
787
+ sufficient for both recurring tasks here — no bespoke scheduler needed
788
+ or invented.
789
+ - **`src/ui/content-status-badge.tsx`** (new) — `ContentStatusBadge`, a
790
+ colour-coded pill for all six `ContentStatus` values, following
791
+ `post-list-table.tsx`'s legacy `StatusPill` visual/structural convention
792
+ (rounded-full, capitalize) but as its own standalone component (the
793
+ legacy one stays legacy-only). Exported from `./ui`'s barrel — no v2
794
+ post-list UI exists yet in this package to embed it in directly (still
795
+ Phase 1a issue 12/15's job on the platform side), same situation
796
+ `hasPendingChanges` documented in 2.1.0 below.
797
+ - **`src/service/posts.ts`**, **`src/next/post-routes.ts`** — thin
798
+ req-scoped service wrappers + three new route factories
799
+ (`createPostStatusRoutes` `POST .../status` body `{ to }`,
800
+ `createPostTrashRoutes` `POST .../trash`, `createPostRestoreRoutes`
801
+ `POST .../restore`), same auth-guard + `ok()`/`fail()` envelope
802
+ convention as every other route in this file.
803
+ `PostInvalidStatusTransitionError` maps to a 409
804
+ `INVALID_STATUS_TRANSITION` envelope.
805
+ - Tests: extends `posts.repository.integration.test.ts` (full transition
806
+ matrix — every valid edge and a representative set of invalid ones, any
807
+ non-trashed status → trashed, trashed → draft restore, tenant isolation,
808
+ the `setStatus(..., 'published')`-promotes-a-staged-draft interaction,
809
+ `findDueScheduledPosts`/`purgeTrashedPosts` cross-tenant behaviour) and
810
+ `posts.service.test.ts` (service wiring); new `status.test.ts` (pure
811
+ `isValidStatusTransition` matrix, no DB), `routes.post-status.test.ts`
812
+ (route-level envelope/auth coverage), `maintenance-jobs.test.ts` (both
813
+ jobs' handlers against a real SQLite adapter, plus
814
+ `registerBlogMaintenanceJobs`'s wiring via a fake `hazo_jobs/server`
815
+ module — see that file's header for why the REAL module is never
816
+ executed inside this package's jest harness, only type-checked against).
817
+ A cross-connection staleness gap in backdating timestamps for these tests
818
+ (a second `better-sqlite3` connection's committed write wasn't visible to
819
+ the long-lived adapter connection on its next query) surfaced while
820
+ writing them — fixed by backdating through `adapter.rawQuery` instead of
821
+ a second connection; documented in both test files' `backdate()` helpers.
822
+
823
+ ## 2.1.0 — 2026-11-06
824
+
825
+ **Live/draft revision promotion (Phase 1a issue 06).** Additive on top of
826
+ 2.0.0's posts + pages foundation (issue 04) — no DDL change, `hazo_blog_posts`'
827
+ `live_revision_id`/`draft_revision_id` and `hazo_blog_revisions` already
828
+ existed; this issue was the promote/discard/compare workflow around them.
829
+ `updatePost()`'s existing behaviour (always stage to `draft_revision_id`,
830
+ never touch `live_revision_id`) turns out to already be correct for both the
831
+ published and unpublished case — confirmed, not changed.
832
+
833
+ - **`src/repository/posts.ts`** — three new `PostRepository` methods:
834
+ - `publishDraft(id)` — promotes `draft_revision_id` to `live_revision_id`,
835
+ clears `draft_revision_id`, bumps `_rev`, and stamps `published_at` on
836
+ first publish only (a republish never clobbers the original timestamp).
837
+ The previous live revision is never deleted — `listRevisions` still
838
+ returns it as history.
839
+ - `discardDraft(id)` — clears `draft_revision_id` only; every other column
840
+ (including `_rev`) is left exactly as it was. Idempotent — a no-op when
841
+ there's nothing staged.
842
+ - `compareDraftAndLive(id)` — fetches the post's live and draft revisions
843
+ and returns both payloads plus a field-level diff summary
844
+ (`PostRevisionDiffField`: title/slug/body/excerpt/seo_title/
845
+ seo_description/og_image/noindex/canonical_override/custom_fields).
846
+ - Two new typed errors: `PostNoDraftRevisionError` (nothing staged) and
847
+ `PostNoLiveRevisionError` (post has never been published) — same
848
+ constructor shape as the existing `PostNotFoundError`/`PostConflictError`.
849
+ - **`src/service/posts.ts`** — `PostService.publishDraft`/`discardDraft`/
850
+ `compareDraftAndLive`, same `req`-scoped wrapper pattern as every other
851
+ method; re-exports the two new error classes.
852
+ - **`src/next/post-routes.ts`** — three new route factories, each mounted on
853
+ its own `/api/.../posts/[id]/...` resource (same pattern as
854
+ `createBlogTranslateRoutes`/`createBlogReviewRoutes` in `./routes.js`):
855
+ `createPostCompareRoutes` (`GET`, `blog:read`), `createPostPublishRoutes`
856
+ (`POST`, `blog:write`), `createPostDiscardRoutes` (`POST`, `blog:write`).
857
+ `PostNoDraftRevisionError`/`PostNoLiveRevisionError` map to 409
858
+ `NO_DRAFT_REVISION`/`NO_LIVE_REVISION` fail() envelopes.
859
+ - **`src/lib/revisions.ts`** (new) — `hasPendingChanges(post)`: a pure
860
+ `live_revision_id !== null && draft_revision_id !== null` predicate for an
861
+ admin "pending changes" badge. No v2-model post-list UI exists yet in this
862
+ package to host an actual badge component in (`src/ui/post-list-table.tsx`
863
+ and `src/components/admin/post-form.tsx` still operate on the v1.6
864
+ `BlogPost` shape) — wiring this into a real list view is Phase 1a issue
865
+ 12/15's job. Re-exported from the `./lib` barrel.
866
+ - Tests: `src/__tests__/posts.repository.integration.test.ts` (publish/
867
+ discard/compare happy paths + edge cases, revision history ordering,
868
+ published vs. unpublished edit flow, tenant isolation),
869
+ `src/__tests__/posts.service.test.ts` (service-layer wiring, real SQLite),
870
+ `src/__tests__/routes.post-revisions.test.ts` (new — route-level auth/scope
871
+ + envelope coverage for the three new endpoints).
872
+
873
+ ## 2.0.0 — 2026-10-31
874
+
875
+ **⚠️ BREAKING — new posts + pages foundation for BlogSketch (Phase 1a issue 04).**
876
+
877
+ Replaces `hazo_blog_posts`' v1.6 shape entirely with a content-type-agnostic
878
+ model: `type` (free-form string, default `'post'`) instead of a fixed
879
+ post/page split; `_rev` optimistic concurrency; `live_revision_id`/
880
+ `draft_revision_id` pointers into a new `hazo_blog_revisions` table (every
881
+ save is an immutable snapshot — the live/draft promotion workflow itself is
882
+ Phase 1a issue 06); `custom_fields` JSONB; a current-value SEO/meta snapshot
883
+ (`seo_title`/`seo_description`/`og_image`/`noindex`/`canonical_override`) on
884
+ the post, with historical snapshots on each revision; `deleted_at` (soft
885
+ delete — restore/auto-purge is issue 07); `publish_at` (scheduled — auto-publish
886
+ worker is issue 07). **`scope_id` is now NOT NULL** on `hazo_blog_posts`/
887
+ `hazo_blog_revisions` — add-on/single-site mode stamps hazo_auth's
888
+ `DEFAULT_SYSTEM_SCOPE_ID` (`resolvePostScope()` in `src/service/posts.ts`,
889
+ via `optional_import("hazo_auth/server-lib")` since `hazo_auth` is an
890
+ optional peer, with a literal fallback if it's not installed).
891
+
892
+ Gone from the table entirely (content/category/faq/i18n fields now live
893
+ elsewhere or are deferred): `content`, `content_html`, `excerpt`,
894
+ `category_id`, `author_id`, `meta_title`, `meta_description`,
895
+ `canonical_url`, `featured_image`, `image_ref`, `faq_json`, `reading_time`,
896
+ `status`, `publish_date`, `translation_of`, `review_notes`, `indexable`.
897
+ `status`/transitions/trash-restore are deliberately NOT part of this issue —
898
+ see issue 07.
899
+
900
+ **This is intentionally a hard break, not a migration.** v1.6 consumers
901
+ (GoTimer, kinstripe) stay on `~1.6.x` and are unaffected — their deployments
902
+ bundle v1.6's own `db_setup_*.sql`. The v1.6 `BlogRepository`/`BlogService`/
903
+ routes/components/SEO glue in this package (`src/repository/index.ts`,
904
+ `src/service/index.ts`, `src/next/routes.ts`, `src/components/*`, `src/ui/*`,
905
+ `src/seo/*`) are UNTOUCHED by this change and now reference a dropped shape
906
+ — their own tests fail against the new schema until later Phase 1a issues
907
+ (05 admin shell, 10 media pipeline, 11 SEO admin, 12 post list, 15 post
908
+ editor) rewrite each consumer against the v2 model. `npx tsc --noEmit`
909
+ stays green (the breakage is schema/runtime, not type-level — `BlogPost`
910
+ etc. are plain interfaces with no DB coupling).
911
+
912
+ New, additive, and NOT touching any v1.6 code path:
913
+
914
+ - **`src/types/index.ts`** — `Post`, `PostRevision`, `CreatePostInput`,
915
+ `UpdatePostInput`, `ListPostsQuery`, `PaginatedPostsResult`.
916
+ - **`src/repository/posts.ts`** — `createPostRepository(adapter, scope)`.
917
+ `updatePost()`'s optimistic-concurrency check is a single atomic
918
+ `UPDATE ... WHERE id = ? AND scope_id = ? AND _rev = ? RETURNING *` (via
919
+ `hazo_connect`'s `QueryBuilder`+`CrudService.query()`) — the WHERE clause
920
+ itself is what prevents a lost update between two concurrent callers, not
921
+ a prior read (that read only exists to tell `PostNotFoundError` apart from
922
+ `PostConflictError` with a clearer error). Postgres full-text search uses
923
+ native `tsvector`/`ts_rank` via a duck-typed `adapter.raw()` escape hatch
924
+ (PgAdapter-only — not part of the common `HazoConnectAdapter` interface;
925
+ every other adapter, including PostgREST/Supabase, degrades to a plain
926
+ title substring match). SQLite add-on mode gets a stub `hazo_blog_posts_fts`
927
+ FTS5 table, kept in sync on write but not yet wired into `searchPosts()`.
928
+ - **`src/service/posts.ts`** — `createPostService(config)`, `resolvePostScope()`.
929
+ Fails CLOSED (`ScopeUnresolvedError`) when a multi-site `resolveScope` is
930
+ configured but returns no scope for a request — only falls back to
931
+ hazo_auth's `DEFAULT_SYSTEM_SCOPE_ID` when no `resolveScope` is configured
932
+ at all. Defaulting to the system scope on an unresolved multi-tenant
933
+ request would be a fail-open tenant-isolation bug, not a convenience.
934
+ - **`src/next/post-routes.ts`** — `createPostManageRoutes`/`createPostItemRoutes`
935
+ (same `blog:read`/`blog:write` hazo_api scopes as v1.6's manage routes).
936
+ - **`db_setup_sqlite.sql`/`db_setup_postgres.sql`/`migrations/*`** — new
937
+ `hazo_blog_posts` shape, new `hazo_blog_revisions` table, new
938
+ `hazo_blog_posts_fts` SQLite FTS5 stub. `hazo_blog_categories`/
939
+ `hazo_blog_tags`/`hazo_blog_post_tags`/`hazo_blog_settings` are unchanged
940
+ and not yet wired to v2 posts (categorization is deferred to a later
941
+ issue).
942
+
6
943
  ## 1.6.0 — 2026-10-09
7
944
 
945
+ **Fix — `getPostById` ignored tenant scoping (IDOR).**
946
+
947
+ `repository.getPostById(id)` used `posts.findById(id)` directly, with no `scope_id`
948
+ predicate — unlike every other read in this file (`getPostBySlug`, `getAllSlugs`, `listPosts`),
949
+ which all go through the `scoped()` helper. One tenant could fetch (and, via the new
950
+ translate/review flow below, read/mutate) another tenant's post by guessing or enumerating its
951
+ id. Fixed by routing `getPostById` through `posts.list()` with the same `scoped(qb)` predicate
952
+ the slug lookup uses.
953
+
8
954
  **Blog translation, Phase 4C: AI translate + AI review of translation-cluster drafts**
9
955
 
10
956
  Takes a Phase 4B draft (created via `createTranslation()`, content copied verbatim,