hazo_blog 1.6.0 → 2.0.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 CHANGED
@@ -3,8 +3,87 @@
3
3
  All notable changes are documented here. This project follows
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## 2.0.0 — 2026-10-31
7
+
8
+ **⚠️ BREAKING — new posts + pages foundation for BlogSketch (Phase 1a issue 04).**
9
+
10
+ Replaces `hazo_blog_posts`' v1.6 shape entirely with a content-type-agnostic
11
+ model: `type` (free-form string, default `'post'`) instead of a fixed
12
+ post/page split; `_rev` optimistic concurrency; `live_revision_id`/
13
+ `draft_revision_id` pointers into a new `hazo_blog_revisions` table (every
14
+ save is an immutable snapshot — the live/draft promotion workflow itself is
15
+ Phase 1a issue 06); `custom_fields` JSONB; a current-value SEO/meta snapshot
16
+ (`seo_title`/`seo_description`/`og_image`/`noindex`/`canonical_override`) on
17
+ the post, with historical snapshots on each revision; `deleted_at` (soft
18
+ delete — restore/auto-purge is issue 07); `publish_at` (scheduled — auto-publish
19
+ worker is issue 07). **`scope_id` is now NOT NULL** on `hazo_blog_posts`/
20
+ `hazo_blog_revisions` — add-on/single-site mode stamps hazo_auth's
21
+ `DEFAULT_SYSTEM_SCOPE_ID` (`resolvePostScope()` in `src/service/posts.ts`,
22
+ via `optional_import("hazo_auth/server-lib")` since `hazo_auth` is an
23
+ optional peer, with a literal fallback if it's not installed).
24
+
25
+ Gone from the table entirely (content/category/faq/i18n fields now live
26
+ elsewhere or are deferred): `content`, `content_html`, `excerpt`,
27
+ `category_id`, `author_id`, `meta_title`, `meta_description`,
28
+ `canonical_url`, `featured_image`, `image_ref`, `faq_json`, `reading_time`,
29
+ `status`, `publish_date`, `translation_of`, `review_notes`, `indexable`.
30
+ `status`/transitions/trash-restore are deliberately NOT part of this issue —
31
+ see issue 07.
32
+
33
+ **This is intentionally a hard break, not a migration.** v1.6 consumers
34
+ (GoTimer, kinstripe) stay on `~1.6.x` and are unaffected — their deployments
35
+ bundle v1.6's own `db_setup_*.sql`. The v1.6 `BlogRepository`/`BlogService`/
36
+ routes/components/SEO glue in this package (`src/repository/index.ts`,
37
+ `src/service/index.ts`, `src/next/routes.ts`, `src/components/*`, `src/ui/*`,
38
+ `src/seo/*`) are UNTOUCHED by this change and now reference a dropped shape
39
+ — their own tests fail against the new schema until later Phase 1a issues
40
+ (05 admin shell, 10 media pipeline, 11 SEO admin, 12 post list, 15 post
41
+ editor) rewrite each consumer against the v2 model. `npx tsc --noEmit`
42
+ stays green (the breakage is schema/runtime, not type-level — `BlogPost`
43
+ etc. are plain interfaces with no DB coupling).
44
+
45
+ New, additive, and NOT touching any v1.6 code path:
46
+
47
+ - **`src/types/index.ts`** — `Post`, `PostRevision`, `CreatePostInput`,
48
+ `UpdatePostInput`, `ListPostsQuery`, `PaginatedPostsResult`.
49
+ - **`src/repository/posts.ts`** — `createPostRepository(adapter, scope)`.
50
+ `updatePost()`'s optimistic-concurrency check is a single atomic
51
+ `UPDATE ... WHERE id = ? AND scope_id = ? AND _rev = ? RETURNING *` (via
52
+ `hazo_connect`'s `QueryBuilder`+`CrudService.query()`) — the WHERE clause
53
+ itself is what prevents a lost update between two concurrent callers, not
54
+ a prior read (that read only exists to tell `PostNotFoundError` apart from
55
+ `PostConflictError` with a clearer error). Postgres full-text search uses
56
+ native `tsvector`/`ts_rank` via a duck-typed `adapter.raw()` escape hatch
57
+ (PgAdapter-only — not part of the common `HazoConnectAdapter` interface;
58
+ every other adapter, including PostgREST/Supabase, degrades to a plain
59
+ title substring match). SQLite add-on mode gets a stub `hazo_blog_posts_fts`
60
+ FTS5 table, kept in sync on write but not yet wired into `searchPosts()`.
61
+ - **`src/service/posts.ts`** — `createPostService(config)`, `resolvePostScope()`.
62
+ Fails CLOSED (`ScopeUnresolvedError`) when a multi-site `resolveScope` is
63
+ configured but returns no scope for a request — only falls back to
64
+ hazo_auth's `DEFAULT_SYSTEM_SCOPE_ID` when no `resolveScope` is configured
65
+ at all. Defaulting to the system scope on an unresolved multi-tenant
66
+ request would be a fail-open tenant-isolation bug, not a convenience.
67
+ - **`src/next/post-routes.ts`** — `createPostManageRoutes`/`createPostItemRoutes`
68
+ (same `blog:read`/`blog:write` hazo_api scopes as v1.6's manage routes).
69
+ - **`db_setup_sqlite.sql`/`db_setup_postgres.sql`/`migrations/*`** — new
70
+ `hazo_blog_posts` shape, new `hazo_blog_revisions` table, new
71
+ `hazo_blog_posts_fts` SQLite FTS5 stub. `hazo_blog_categories`/
72
+ `hazo_blog_tags`/`hazo_blog_post_tags`/`hazo_blog_settings` are unchanged
73
+ and not yet wired to v2 posts (categorization is deferred to a later
74
+ issue).
75
+
6
76
  ## 1.6.0 — 2026-10-09
7
77
 
78
+ **Fix — `getPostById` ignored tenant scoping (IDOR).**
79
+
80
+ `repository.getPostById(id)` used `posts.findById(id)` directly, with no `scope_id`
81
+ predicate — unlike every other read in this file (`getPostBySlug`, `getAllSlugs`, `listPosts`),
82
+ which all go through the `scoped()` helper. One tenant could fetch (and, via the new
83
+ translate/review flow below, read/mutate) another tenant's post by guessing or enumerating its
84
+ id. Fixed by routing `getPostById` through `posts.list()` with the same `scoped(qb)` predicate
85
+ the slug lookup uses.
86
+
8
87
  **Blog translation, Phase 4C: AI translate + AI review of translation-cluster drafts**
9
88
 
10
89
  Takes a Phase 4B draft (created via `createTranslation()`, content copied verbatim,
@@ -3,9 +3,11 @@
3
3
  -- Supports both PostgreSQL (this file) and SQLite (db_setup_sqlite.sql).
4
4
  -- All tables use the `hazo_blog_` prefix per workspace standards.
5
5
  --
6
- -- Multi-tenancy: every table carries a nullable `scope_id`. Single-tenant
7
- -- consumers leave it NULL; multi-tenant consumers populate it and pass a scope
8
- -- resolver so the repository filters by it.
6
+ -- Multi-tenancy: hazo_blog_posts/hazo_blog_revisions require a `scope_id`
7
+ -- (NOT NULL, as of v2.0.0) — add-on/single-site mode stamps hazo_auth's
8
+ -- DEFAULT_SYSTEM_SCOPE_ID. Every other table still carries a nullable
9
+ -- `scope_id`: single-tenant consumers leave it NULL; multi-tenant consumers
10
+ -- populate it and pass a scope resolver so the repository filters by it.
9
11
 
10
12
  CREATE TABLE IF NOT EXISTS hazo_blog_categories (
11
13
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -42,47 +44,81 @@ DROP INDEX IF EXISTS idx_hazo_blog_tags_scope_slug;
42
44
  CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_tags_scope_slug_locale
43
45
  ON hazo_blog_tags (scope_id, slug, COALESCE(locale, ''));
44
46
 
47
+ -- hazo_blog_posts (v2.0.0) — content-type-agnostic foundation for posts and
48
+ -- pages. Supersedes the v1.6 shape: status/category_id/faq_json/content/etc.
49
+ -- are gone. `type` picks post vs page vs any site-defined type; the actual
50
+ -- MDX body + excerpt + per-save SEO snapshot live on hazo_blog_revisions, not
51
+ -- here — this table only carries the current pointer (_rev/live/draft) plus
52
+ -- the SEO/meta fields needed to render without a join.
53
+ --
54
+ -- scope_id is NOT NULL (unlike every other hazo_blog table): v2 requires a
55
+ -- concrete tenant for every post, including add-on/single-site mode, which
56
+ -- stamps hazo_auth's DEFAULT_SYSTEM_SCOPE_ID rather than leaving it NULL.
57
+ --
58
+ -- live_revision_id/draft_revision_id intentionally carry no FK constraint:
59
+ -- hazo_blog_revisions.post_id references this table, so a FK the other way
60
+ -- would be circular at CREATE TABLE time. The repository enforces the
61
+ -- pointer's integrity (see src/repository/posts.ts).
62
+ --
63
+ -- search_vector is maintained by the repository, not a generated column —
64
+ -- Postgres generated columns can't reach across tables, and the searchable
65
+ -- body text lives on hazo_blog_revisions.
45
66
  CREATE TABLE IF NOT EXISTS hazo_blog_posts (
46
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
47
- scope_id TEXT,
48
- slug TEXT NOT NULL,
49
- title TEXT NOT NULL,
50
- content TEXT NOT NULL DEFAULT '', -- raw MDX
51
- content_html TEXT, -- cached rendered HTML (nullable)
52
- excerpt TEXT,
53
- category_id UUID REFERENCES hazo_blog_categories(id) ON DELETE SET NULL,
54
- author_id TEXT, -- optional; resolved via getAuthor()
55
- meta_title TEXT NOT NULL DEFAULT '',
56
- meta_description TEXT NOT NULL DEFAULT '',
57
- canonical_url TEXT, -- per-post canonical override
58
- og_image TEXT, -- per-post OG image override
59
- featured_image TEXT,
60
- image_ref TEXT, -- generic image ref (was character_id)
61
- faq_json JSONB NOT NULL DEFAULT '[]', -- [{question, answer}]
62
- reading_time INTEGER NOT NULL DEFAULT 0, -- cached, minutes
63
- status TEXT NOT NULL DEFAULT 'draft', -- draft | published | scheduled
64
- publish_date TIMESTAMPTZ,
65
- locale TEXT DEFAULT NULL, -- BCP-47 locale; NULL = default locale
66
- translation_of UUID REFERENCES hazo_blog_posts(id) ON DELETE SET NULL, -- root post of this translation cluster
67
- review_notes TEXT, -- BlogService#reviewTranslatedPost()'s LLM notes (nullable; never flips status)
68
- indexable BOOLEAN NOT NULL DEFAULT TRUE, -- FALSE excludes from sitemap/robots
69
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
70
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
67
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
68
+ scope_id TEXT NOT NULL,
69
+ type TEXT NOT NULL DEFAULT 'post',
70
+ slug TEXT NOT NULL,
71
+ title TEXT NOT NULL,
72
+ _rev INTEGER NOT NULL DEFAULT 1,
73
+ live_revision_id UUID,
74
+ draft_revision_id UUID,
75
+ deleted_at TIMESTAMPTZ,
76
+ template TEXT,
77
+ locale TEXT DEFAULT NULL, -- BCP-47 locale; NULL = default locale
78
+ custom_fields JSONB NOT NULL DEFAULT '{}'::jsonb,
79
+ seo_title TEXT,
80
+ seo_description TEXT,
81
+ og_image TEXT,
82
+ noindex BOOLEAN NOT NULL DEFAULT FALSE,
83
+ canonical_override TEXT,
84
+ search_vector tsvector,
85
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
86
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
87
+ published_at TIMESTAMPTZ,
88
+ publish_at TIMESTAMPTZ
71
89
  );
72
- ALTER TABLE hazo_blog_posts ADD COLUMN IF NOT EXISTS locale TEXT DEFAULT NULL;
73
- ALTER TABLE hazo_blog_posts ADD COLUMN IF NOT EXISTS translation_of UUID REFERENCES hazo_blog_posts(id) ON DELETE SET NULL;
74
- ALTER TABLE hazo_blog_posts ADD COLUMN IF NOT EXISTS indexable BOOLEAN NOT NULL DEFAULT TRUE;
75
- ALTER TABLE hazo_blog_posts ADD COLUMN IF NOT EXISTS review_notes TEXT;
76
- -- FR-034: locale-aware uniqueness (see categories index comment above).
77
- DROP INDEX IF EXISTS idx_hazo_blog_posts_scope_slug;
78
90
  CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_posts_scope_slug_locale
79
91
  ON hazo_blog_posts (scope_id, slug, COALESCE(locale, ''));
80
- CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_status_date
81
- ON hazo_blog_posts (status, publish_date);
82
- CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_category
83
- ON hazo_blog_posts (category_id);
84
- CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_translation_of
85
- ON hazo_blog_posts (translation_of);
92
+ CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_scope_type
93
+ ON hazo_blog_posts (scope_id, type);
94
+ CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_deleted_at
95
+ ON hazo_blog_posts (deleted_at);
96
+ CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_publish_at
97
+ ON hazo_blog_posts (publish_at);
98
+ CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_search
99
+ ON hazo_blog_posts USING GIN (search_vector);
100
+
101
+ -- hazo_blog_revisions: full immutable snapshot on every save (draft or
102
+ -- publish). posts.live_revision_id/draft_revision_id point into this table.
103
+ CREATE TABLE IF NOT EXISTS hazo_blog_revisions (
104
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
105
+ post_id UUID NOT NULL REFERENCES hazo_blog_posts(id) ON DELETE CASCADE,
106
+ scope_id TEXT NOT NULL,
107
+ title TEXT NOT NULL,
108
+ slug TEXT NOT NULL,
109
+ body TEXT NOT NULL DEFAULT '',
110
+ excerpt TEXT,
111
+ custom_fields JSONB NOT NULL DEFAULT '{}'::jsonb,
112
+ seo_title TEXT,
113
+ seo_description TEXT,
114
+ og_image TEXT,
115
+ noindex BOOLEAN NOT NULL DEFAULT FALSE,
116
+ canonical_override TEXT,
117
+ created_by TEXT,
118
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
119
+ );
120
+ CREATE INDEX IF NOT EXISTS idx_hazo_blog_revisions_post_created
121
+ ON hazo_blog_revisions (post_id, created_at);
86
122
 
87
123
  CREATE TABLE IF NOT EXISTS hazo_blog_post_tags (
88
124
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -4,15 +4,20 @@
4
4
  -- All tables use the `hazo_blog_` prefix per workspace standards.
5
5
  --
6
6
  -- Tables:
7
- -- hazo_blog_posts — blog posts (raw MDX content + cached HTML + SEO fields)
8
- -- hazo_blog_categories — single category per post
9
- -- hazo_blog_tags — free-form tags (many-to-many with posts)
7
+ -- hazo_blog_posts — posts/pages (v2.0.0: type/_rev/live+draft pointers; see table comment)
8
+ -- hazo_blog_revisions — immutable per-save content snapshots for hazo_blog_posts
9
+ -- hazo_blog_posts_fts — FTS5 stub search index for hazo_blog_posts (add-on/SQLite mode)
10
+ -- hazo_blog_categories — single category per post (v1.6; not yet wired to v2 posts)
11
+ -- hazo_blog_tags — free-form tags (many-to-many with posts; v1.6, not yet wired to v2 posts)
10
12
  -- hazo_blog_post_tags — post↔tag join
11
13
  -- hazo_blog_settings — package-owned display/behavior settings
12
14
  --
13
- -- Multi-tenancy: every table carries a nullable `scope_id`. Single-tenant
14
- -- consumers (e.g. gotimer) leave it NULL; multi-tenant consumers populate it
15
- -- and pass a scope resolver so the repository filters by it.
15
+ -- Multi-tenancy: hazo_blog_posts/hazo_blog_revisions require a `scope_id`
16
+ -- (NOT NULL, as of v2.0.0) — add-on/single-site mode stamps hazo_auth's
17
+ -- DEFAULT_SYSTEM_SCOPE_ID. Every other table still carries a nullable
18
+ -- `scope_id`: single-tenant consumers (e.g. gotimer, on v1.6) leave it NULL;
19
+ -- multi-tenant consumers populate it and pass a scope resolver so the
20
+ -- repository filters by it.
16
21
 
17
22
  CREATE TABLE IF NOT EXISTS hazo_blog_categories (
18
23
  id TEXT PRIMARY KEY,
@@ -48,43 +53,84 @@ DROP INDEX IF EXISTS idx_hazo_blog_tags_scope_slug;
48
53
  CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_tags_scope_slug_locale
49
54
  ON hazo_blog_tags (scope_id, slug, COALESCE(locale, ''));
50
55
 
56
+ -- hazo_blog_posts (v2.0.0) — content-type-agnostic foundation for posts and
57
+ -- pages. Supersedes the v1.6 shape: status/category_id/faq_json/content/etc.
58
+ -- are gone. `type` picks post vs page vs any site-defined type; the actual
59
+ -- MDX body + excerpt + per-save SEO snapshot live on hazo_blog_revisions, not
60
+ -- here — this table only carries the current pointer (_rev/live/draft) plus
61
+ -- the SEO/meta fields needed to render without a join.
62
+ --
63
+ -- scope_id is NOT NULL (unlike every other hazo_blog table): v2 requires a
64
+ -- concrete tenant for every post, including add-on/single-site mode, which
65
+ -- stamps hazo_auth's DEFAULT_SYSTEM_SCOPE_ID rather than leaving it NULL.
66
+ --
67
+ -- live_revision_id/draft_revision_id intentionally carry no FK constraint:
68
+ -- hazo_blog_revisions.post_id references this table, so a FK the other way
69
+ -- would be circular at CREATE TABLE time. The repository enforces the
70
+ -- pointer's integrity (see src/repository/posts.ts).
51
71
  CREATE TABLE IF NOT EXISTS hazo_blog_posts (
52
- id TEXT PRIMARY KEY,
53
- scope_id TEXT,
54
- slug TEXT NOT NULL,
55
- title TEXT NOT NULL,
56
- content TEXT NOT NULL DEFAULT '', -- raw MDX
57
- content_html TEXT, -- cached rendered HTML (nullable)
58
- excerpt TEXT,
59
- category_id TEXT REFERENCES hazo_blog_categories(id) ON DELETE SET NULL,
60
- author_id TEXT, -- optional; resolved via getAuthor()
61
- meta_title TEXT NOT NULL DEFAULT '',
62
- meta_description TEXT NOT NULL DEFAULT '',
63
- canonical_url TEXT, -- per-post canonical override
64
- og_image TEXT, -- per-post OG image override
65
- featured_image TEXT,
66
- image_ref TEXT, -- generic image ref (was character_id)
67
- faq_json TEXT NOT NULL DEFAULT '[]', -- [{question, answer}]
68
- reading_time INTEGER NOT NULL DEFAULT 0, -- cached, minutes
69
- status TEXT NOT NULL DEFAULT 'draft', -- draft | published | scheduled
70
- publish_date TEXT,
71
- locale TEXT DEFAULT NULL, -- BCP-47 locale; NULL = default locale
72
- translation_of TEXT REFERENCES hazo_blog_posts(id) ON DELETE SET NULL, -- root post of this translation cluster
73
- review_notes TEXT, -- BlogService#reviewTranslatedPost()'s LLM notes (nullable; never flips status)
74
- indexable INTEGER NOT NULL DEFAULT 1, -- 0/1; FALSE excludes from sitemap/robots
75
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
76
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
72
+ id TEXT PRIMARY KEY,
73
+ scope_id TEXT NOT NULL,
74
+ type TEXT NOT NULL DEFAULT 'post',
75
+ slug TEXT NOT NULL,
76
+ title TEXT NOT NULL,
77
+ _rev INTEGER NOT NULL DEFAULT 1,
78
+ live_revision_id TEXT,
79
+ draft_revision_id TEXT,
80
+ deleted_at TEXT,
81
+ template TEXT,
82
+ locale TEXT DEFAULT NULL, -- BCP-47 locale; NULL = default locale
83
+ custom_fields TEXT NOT NULL DEFAULT '{}', -- JSON object
84
+ seo_title TEXT,
85
+ seo_description TEXT,
86
+ og_image TEXT,
87
+ noindex INTEGER NOT NULL DEFAULT 0, -- 0/1
88
+ canonical_override TEXT,
89
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
90
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
91
+ published_at TEXT,
92
+ publish_at TEXT -- scheduled publish time
77
93
  );
78
- -- FR-034: locale-aware uniqueness (see categories index comment above).
79
- DROP INDEX IF EXISTS idx_hazo_blog_posts_scope_slug;
80
94
  CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_posts_scope_slug_locale
81
95
  ON hazo_blog_posts (scope_id, slug, COALESCE(locale, ''));
82
- CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_status_date
83
- ON hazo_blog_posts (status, publish_date);
84
- CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_category
85
- ON hazo_blog_posts (category_id);
86
- CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_translation_of
87
- ON hazo_blog_posts (translation_of);
96
+ CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_scope_type
97
+ ON hazo_blog_posts (scope_id, type);
98
+ CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_deleted_at
99
+ ON hazo_blog_posts (deleted_at);
100
+ CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_publish_at
101
+ ON hazo_blog_posts (publish_at);
102
+
103
+ -- Stub FTS5 index for add-on (SQLite) mode — see db_setup_postgres.sql's
104
+ -- search_vector/GIN index for the real multi-tenant implementation. Kept in
105
+ -- sync by the repository (not SQLite's external-content mechanism: that
106
+ -- needs an INTEGER rowid content table, and this PK is TEXT).
107
+ CREATE VIRTUAL TABLE IF NOT EXISTS hazo_blog_posts_fts USING fts5(
108
+ post_id UNINDEXED,
109
+ title,
110
+ body
111
+ );
112
+
113
+ -- hazo_blog_revisions: full immutable snapshot on every save (draft or
114
+ -- publish). posts.live_revision_id/draft_revision_id point into this table.
115
+ CREATE TABLE IF NOT EXISTS hazo_blog_revisions (
116
+ id TEXT PRIMARY KEY,
117
+ post_id TEXT NOT NULL REFERENCES hazo_blog_posts(id) ON DELETE CASCADE,
118
+ scope_id TEXT NOT NULL,
119
+ title TEXT NOT NULL,
120
+ slug TEXT NOT NULL,
121
+ body TEXT NOT NULL DEFAULT '', -- raw MDX
122
+ excerpt TEXT,
123
+ custom_fields TEXT NOT NULL DEFAULT '{}', -- JSON object
124
+ seo_title TEXT,
125
+ seo_description TEXT,
126
+ og_image TEXT,
127
+ noindex INTEGER NOT NULL DEFAULT 0,
128
+ canonical_override TEXT,
129
+ created_by TEXT,
130
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
131
+ );
132
+ CREATE INDEX IF NOT EXISTS idx_hazo_blog_revisions_post_created
133
+ ON hazo_blog_revisions (post_id, created_at);
88
134
 
89
135
  CREATE TABLE IF NOT EXISTS hazo_blog_post_tags (
90
136
  id TEXT PRIMARY KEY,
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/repository/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/repository/index.ts"],"names":[],"mappings":"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"}
@@ -172,7 +172,13 @@ export function createBlogRepository(adapter, options = {}) {
172
172
  return hydrated ?? null;
173
173
  },
174
174
  async getPostById(id) {
175
- const row = await posts.findById(id);
175
+ // Not findById: findById has no scope predicate, which would let one
176
+ // tenant fetch (and, via the translate/review flow, mutate) another
177
+ // tenant's post by guessing its id.
178
+ const rows = await posts.list(
179
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
180
+ (qb) => scoped(qb).where("id", "eq", id).limit(1));
181
+ const row = rows[0] ?? null;
176
182
  if (!row)
177
183
  return null;
178
184
  const [hydrated] = await hydrate([row]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hazo_blog",
3
- "version": "1.6.0",
3
+ "version": "2.0.0",
4
4
  "description": "SEO-optimized blogging package: posts, categories, tags, MDX content, and GA4/GSC/Bing-ready SEO.",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",
@@ -52,17 +52,17 @@
52
52
  "build:test-app": "npm run build && cd test-app && npm run build"
53
53
  },
54
54
  "peerDependencies": {
55
- "hazo_core": "^2.4.0",
56
- "hazo_connect": "^4.3.2",
57
- "hazo_api": "^2.8.0",
55
+ "hazo_core": "^2.5.0",
56
+ "hazo_connect": "^4.3.5",
57
+ "hazo_api": "^2.9.0",
58
58
  "hazo_files": "^3.4.0",
59
- "hazo_theme": "^1.4.0",
60
- "hazo_ui": "^6.14.0",
61
- "hazo_images": "^1.11.0",
62
- "hazo_jobs": "^0.19.0",
63
- "hazo_auth": "^10.23.3",
64
- "hazo_i18n": "^0.12.0",
65
- "hazo_llm_api": "^2.6.0",
59
+ "hazo_theme": "^1.4.2",
60
+ "hazo_ui": "^6.19.0",
61
+ "hazo_images": "^1.13.0",
62
+ "hazo_jobs": "^0.20.0",
63
+ "hazo_auth": "^10.27.0",
64
+ "hazo_i18n": "^0.15.4",
65
+ "hazo_llm_api": "^2.7.0",
66
66
  "react": "^18.0.0 || ^19.0.0",
67
67
  "react-dom": "^18.0.0 || ^19.0.0",
68
68
  "next": "^14.0.0 || ^16.0.0"
@@ -103,11 +103,11 @@
103
103
  "react": "^19.0.0",
104
104
  "react-dom": "^19.0.0",
105
105
  "next": "^16.0.10",
106
- "hazo_core": "^2.4.0",
107
- "hazo_connect": "^4.3.2",
108
- "hazo_api": "^2.8.0",
106
+ "hazo_core": "^2.5.0",
107
+ "hazo_connect": "^4.3.5",
108
+ "hazo_api": "^2.9.0",
109
109
  "hazo_files": "^3.4.0",
110
- "hazo_ui": "^6.14.0",
110
+ "hazo_ui": "^6.19.0",
111
111
  "tailwindcss": "^4.2.4",
112
112
  "@tailwindcss/postcss": "^4.2.4",
113
113
  "postcss": "^8.4.49"