hazo_blog 2.0.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.
- package/CHANGE_LOG.md +867 -0
- package/README.md +1 -0
- package/db_setup_postgres.sql +652 -0
- package/db_setup_sqlite.sql +341 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -0
- package/dist/jobs/maintenance-jobs.d.ts +83 -0
- package/dist/jobs/maintenance-jobs.d.ts.map +1 -0
- package/dist/jobs/maintenance-jobs.js +123 -0
- package/dist/lib/domain-dns.d.ts +15 -0
- package/dist/lib/domain-dns.d.ts.map +1 -0
- package/dist/lib/domain-dns.js +27 -0
- package/dist/lib/domain-token.d.ts +23 -0
- package/dist/lib/domain-token.d.ts.map +1 -0
- package/dist/lib/domain-token.js +42 -0
- package/dist/lib/hook-bus.d.ts +165 -0
- package/dist/lib/hook-bus.d.ts.map +1 -0
- package/dist/lib/hook-bus.js +90 -0
- package/dist/lib/index.d.ts +8 -0
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +8 -0
- package/dist/lib/revisions.d.ts +10 -0
- package/dist/lib/revisions.d.ts.map +1 -0
- package/dist/lib/revisions.js +18 -0
- package/dist/lib/safe-url.d.ts +8 -0
- package/dist/lib/safe-url.d.ts.map +1 -0
- package/dist/lib/safe-url.js +32 -0
- package/dist/lib/sections.d.ts +59 -0
- package/dist/lib/sections.d.ts.map +1 -0
- package/dist/lib/sections.js +303 -0
- package/dist/lib/site-identity.d.ts +45 -0
- package/dist/lib/site-identity.d.ts.map +1 -0
- package/dist/lib/site-identity.js +116 -0
- package/dist/lib/status.d.ts +25 -0
- package/dist/lib/status.d.ts.map +1 -0
- package/dist/lib/status.js +58 -0
- package/dist/lib/theme-schema.d.ts +51 -0
- package/dist/lib/theme-schema.d.ts.map +1 -0
- package/dist/lib/theme-schema.js +112 -0
- package/dist/lib/theme-validate.d.ts +48 -0
- package/dist/lib/theme-validate.d.ts.map +1 -0
- package/dist/lib/theme-validate.js +149 -0
- package/dist/next/byline-routes.d.ts +67 -0
- package/dist/next/byline-routes.d.ts.map +1 -0
- package/dist/next/byline-routes.js +235 -0
- package/dist/next/domain-routes.d.ts +9 -0
- package/dist/next/domain-routes.d.ts.map +1 -0
- package/dist/next/domain-routes.js +39 -0
- package/dist/next/post-routes.d.ts +100 -0
- package/dist/next/post-routes.d.ts.map +1 -0
- package/dist/next/post-routes.js +291 -0
- package/dist/render/engine.d.ts +41 -0
- package/dist/render/engine.d.ts.map +1 -0
- package/dist/render/engine.js +91 -0
- package/dist/render/fallback-theme/templates/404.liquid +1 -0
- package/dist/render/fallback-theme/templates/default.liquid +1 -0
- package/dist/render/fallback-theme.d.ts +3 -0
- package/dist/render/fallback-theme.d.ts.map +1 -0
- package/dist/render/fallback-theme.js +34 -0
- package/dist/render/html.d.ts +10 -0
- package/dist/render/html.d.ts.map +1 -0
- package/dist/render/html.js +29 -0
- package/dist/render/index.d.ts +13 -0
- package/dist/render/index.d.ts.map +1 -0
- package/dist/render/index.js +23 -0
- package/dist/render/resolve.d.ts +23 -0
- package/dist/render/resolve.d.ts.map +1 -0
- package/dist/render/resolve.js +32 -0
- package/dist/render/tags/faq.d.ts +9 -0
- package/dist/render/tags/faq.d.ts.map +1 -0
- package/dist/render/tags/faq.js +31 -0
- package/dist/render/tags/image.d.ts +12 -0
- package/dist/render/tags/image.d.ts.map +1 -0
- package/dist/render/tags/image.js +56 -0
- package/dist/render/tags/index.d.ts +3 -0
- package/dist/render/tags/index.d.ts.map +1 -0
- package/dist/render/tags/index.js +16 -0
- package/dist/render/tags/menu.d.ts +11 -0
- package/dist/render/tags/menu.d.ts.map +1 -0
- package/dist/render/tags/menu.js +74 -0
- package/dist/render/tags/related-posts.d.ts +11 -0
- package/dist/render/tags/related-posts.d.ts.map +1 -0
- package/dist/render/tags/related-posts.js +34 -0
- package/dist/render/tags/section-list.d.ts +9 -0
- package/dist/render/tags/section-list.d.ts.map +1 -0
- package/dist/render/tags/section-list.js +71 -0
- package/dist/render/tags/seo-head.d.ts +16 -0
- package/dist/render/tags/seo-head.d.ts.map +1 -0
- package/dist/render/tags/seo-head.js +73 -0
- package/dist/render/tags/toc.d.ts +15 -0
- package/dist/render/tags/toc.d.ts.map +1 -0
- package/dist/render/tags/toc.js +72 -0
- package/dist/render/types.d.ts +167 -0
- package/dist/render/types.d.ts.map +1 -0
- package/dist/render/types.js +13 -0
- package/dist/repository/bylines.d.ts +56 -0
- package/dist/repository/bylines.d.ts.map +1 -0
- package/dist/repository/bylines.js +284 -0
- package/dist/repository/domains.d.ts +67 -0
- package/dist/repository/domains.d.ts.map +1 -0
- package/dist/repository/domains.js +173 -0
- package/dist/repository/media.d.ts +45 -0
- package/dist/repository/media.d.ts.map +1 -0
- package/dist/repository/media.js +248 -0
- package/dist/repository/menus.d.ts +58 -0
- package/dist/repository/menus.d.ts.map +1 -0
- package/dist/repository/menus.js +311 -0
- package/dist/repository/posts.d.ts +171 -0
- package/dist/repository/posts.d.ts.map +1 -0
- package/dist/repository/posts.js +791 -0
- package/dist/repository/site-files.d.ts +15 -0
- package/dist/repository/site-files.d.ts.map +1 -0
- package/dist/repository/site-files.js +73 -0
- package/dist/repository/tags.d.ts +51 -0
- package/dist/repository/tags.d.ts.map +1 -0
- package/dist/repository/tags.js +282 -0
- package/dist/repository/themes.d.ts +45 -0
- package/dist/repository/themes.d.ts.map +1 -0
- package/dist/repository/themes.js +285 -0
- package/dist/seo/hazo-seo-sitemap-source.d.ts +63 -0
- package/dist/seo/hazo-seo-sitemap-source.d.ts.map +1 -0
- package/dist/seo/hazo-seo-sitemap-source.js +85 -0
- package/dist/seo/index.d.ts +1 -0
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +6 -0
- package/dist/service/bylines.d.ts +21 -0
- package/dist/service/bylines.d.ts.map +1 -0
- package/dist/service/bylines.js +48 -0
- package/dist/service/cdn-hooks.d.ts +20 -0
- package/dist/service/cdn-hooks.d.ts.map +1 -0
- package/dist/service/cdn-hooks.js +38 -0
- package/dist/service/domains.d.ts +49 -0
- package/dist/service/domains.d.ts.map +1 -0
- package/dist/service/domains.js +98 -0
- package/dist/service/media.d.ts +79 -0
- package/dist/service/media.d.ts.map +1 -0
- package/dist/service/media.js +225 -0
- package/dist/service/menus.d.ts +43 -0
- package/dist/service/menus.d.ts.map +1 -0
- package/dist/service/menus.js +165 -0
- package/dist/service/posts.d.ts +70 -0
- package/dist/service/posts.d.ts.map +1 -0
- package/dist/service/posts.js +291 -0
- package/dist/service/seo-hooks.d.ts +18 -0
- package/dist/service/seo-hooks.d.ts.map +1 -0
- package/dist/service/seo-hooks.js +108 -0
- package/dist/service/site-files.d.ts +20 -0
- package/dist/service/site-files.d.ts.map +1 -0
- package/dist/service/site-files.js +71 -0
- package/dist/service/tags.d.ts +20 -0
- package/dist/service/tags.d.ts.map +1 -0
- package/dist/service/tags.js +41 -0
- package/dist/service/themes.d.ts +85 -0
- package/dist/service/themes.d.ts.map +1 -0
- package/dist/service/themes.js +416 -0
- package/dist/types/index.d.ts +953 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/ui/content-status-badge.d.ts +7 -0
- package/dist/ui/content-status-badge.d.ts.map +1 -0
- package/dist/ui/content-status-badge.js +16 -0
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/package.json +37 -26
package/CHANGE_LOG.md
CHANGED
|
@@ -3,6 +3,873 @@
|
|
|
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
|
+
|
|
6
873
|
## 2.0.0 — 2026-10-31
|
|
7
874
|
|
|
8
875
|
**⚠️ BREAKING — new posts + pages foundation for BlogSketch (Phase 1a issue 04).**
|