From 3f367610f54749be01fc7773e54f89e5b4e1a362 Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:03:22 +0100 Subject: [PATCH] fix: force one-time ai: block resync for posts with stale files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v3 patch bug allowed Micropub to update MongoDB with aiTextLevel/ aiCodeLevel values but write post files without the ai: frontmatter block (supportsAiDisclosure was always false). Re-saving with the same values correctly returned "no properties changed" — but the file on disk remained stale. New patch (patch-micropub-ai-block-resync.mjs) adds _aiBlockVersion to each post document in MongoDB. On update, if a post has AI fields but _aiBlockVersion != "v4", the no-change check is bypassed and the file is force-rewritten exactly once. Subsequent no-change saves behave normally. Also adds AI transparency section to README documenting the full implementation, patch chain, v4 root cause, and re-save instructions. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 48 +++++++++ package.json | 4 +- scripts/patch-micropub-ai-block-resync.mjs | 115 +++++++++++++++++++++ 3 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 scripts/patch-micropub-ai-block-resync.mjs diff --git a/README.md b/README.md index 5b3d6de3..32e691a0 100644 --- a/README.md +++ b/README.md @@ -192,3 +192,51 @@ WEBMENTION_SENDER_MOUNT_PATH=/webmention-sender - The indiekit routes rate-limit patch (ported from `rmdes/indiekit-cloudron`) keeps strict limits on `/session/*`, applies generous limits to public API/well-known routes, and removes extra rate limiting from authenticated routes to avoid admin-side 429 spikes. - The indiekit error stack patch (ported from `rmdes/indiekit-cloudron`) suppresses stack traces in production error pages/JSON responses to avoid leaking internal runtime details. - The indieauth dev-mode guard patch prevents accidental production auth bypass by requiring explicit `INDIEKIT_ALLOW_DEV_AUTH=1` to enable dev auto-login, and broadens safe local redirect validation to allow common path characters (`-`, `.`, `%`) used by routes such as `/auth/new-password`. + +## AI transparency + +AI disclosure metadata is captured per-post and surfaced in the blog's frontend as a badge, a sidebar widget, and a full `/ai/` stats page. + +### Frontmatter fields + +Four optional fields are stored under the `ai:` key in each post's frontmatter: + +```yaml +ai: + textLevel: "0" # 0 = none, 1 = editorial, 2 = co-drafted, 3 = AI-generated + codeLevel: "0" # same scale, optional + tools: "" # comma-separated tool names, optional + description: "" # free-text disclosure note, optional +``` + +Articles and notes support all four fields. Other post types (bookmarks, likes, etc.) do not. + +### Backend fields (Micropub form) + +`scripts/patch-endpoint-posts-ai-fields.mjs` patches the Nunjucks templates inside `@indiekit/endpoint-posts` to add `aiTextLevel`, `aiCodeLevel`, `aiTools`, and `aiDescription` inputs to the article/note edit form. `scripts/patch-endpoint-posts-ai-cleanup.mjs` patches `form.js` in the same endpoint to strip empty AI fields from the Micropub payload before submission so unused optional fields are not written as empty strings. + +### Frontmatter generation (preset-eleventy patch) + +`scripts/patch-preset-eleventy-ai-frontmatter.mjs` patches `post-template.js` inside `@rmdes/indiekit-preset-eleventy`. The patch adds a block that writes the `ai:` YAML section from the JF2 `aiTextLevel`/`aiCodeLevel`/`aiTools`/`aiDescription` properties when converting a Micropub post to markdown frontmatter. + +**Root cause of the v4 fix**: `@indiekit/endpoint-micropub/lib/utils.js` — `getPostTemplateProperties()` — explicitly deletes `post-type` before calling `postTemplate()`. Earlier patch versions (v1–v3) relied on `properties["post-type"]` or `properties.postType` to detect whether a post is an article or note, so `supportsAiDisclosure` was always `false` and the `ai:` block was never written. The v4 fix detects post type from `properties.permalink` via URL path regex instead: + +```js +const permalink = String(properties.permalink ?? ""); +const supportsAiDisclosure = + postType === "article" || postType === "note" || + /\/articles(?:\/|$)/.test(permalink) || /\/notes(?:\/|$)/.test(permalink); +``` + +The patch script is idempotent and versioned: it detects the current patch level (v1/v2/v3/upstream) by matching a unique marker string and upgrades to v4 in place. + +### Blog frontend + +- `_includes/layouts/post.njk` — renders an AI disclosure badge below article/note content, reading `ai.textLevel` and `ai.codeLevel` from the post's frontmatter. +- `_includes/components/widgets/ai-usage.njk` — compact sidebar widget showing totals, level breakdown, and a per-year contribution graph. Hidden when no posts have AI metadata. +- `_includes/components/sections/ai-usage.njk` — full-width homepage section version of the same stats. +- `eleventy.config.js` — defines `aiStats` and `aiPosts` Eleventy filters that scan `collections.posts` for `ai.textLevel` values to compute totals, percentages, and by-level counts. + +### Re-saving existing posts + +Posts published before the v4 fix lack the `ai:` frontmatter block. To add it, open each post in the Indiekit backend (`/posts`) and save it again without changes. The patched `postTemplate()` will then write the `ai:` block with default values (`textLevel: "0"`, `codeLevel: "0"`). AI level values previously entered in the form will now be persisted correctly on save. diff --git a/package.json b/package.json index 180ab464..ea7eed4f 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "description": "", "main": "index.js", "scripts": { - "postinstall": "xattr -w com.apple.fileprovider.ignore#P 1 node_modules 2>/dev/null || true && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-activitypub-docloader-loglevel.mjs && node scripts/patch-endpoint-activitypub-private-url-docloader.mjs && node scripts/patch-endpoint-activitypub-migrate-alias-clear.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-posts-ai-fields.mjs && node scripts/patch-endpoint-posts-ai-cleanup.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-conversations-mastodon-disconnect.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs", - "serve": "export NODE_ENV=${NODE_ENV:-production} INDIEKIT_DEBUG=${INDIEKIT_DEBUG:-0} && node scripts/preflight-production-security.mjs && node scripts/preflight-mongo-connection.mjs && node scripts/preflight-activitypub-rsa-key.mjs && node scripts/preflight-activitypub-profile-urls.mjs && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-activitypub-docloader-loglevel.mjs && node scripts/patch-endpoint-activitypub-private-url-docloader.mjs && node scripts/patch-endpoint-activitypub-migrate-alias-clear.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-posts-ai-fields.mjs && node scripts/patch-endpoint-posts-ai-cleanup.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-conversations-mastodon-disconnect.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs", + "postinstall": "xattr -w com.apple.fileprovider.ignore#P 1 node_modules 2>/dev/null || true && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-activitypub-docloader-loglevel.mjs && node scripts/patch-endpoint-activitypub-private-url-docloader.mjs && node scripts/patch-endpoint-activitypub-migrate-alias-clear.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-posts-ai-fields.mjs && node scripts/patch-endpoint-posts-ai-cleanup.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.mjs && node scripts/patch-micropub-ai-block-resync.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-conversations-mastodon-disconnect.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs", + "serve": "export NODE_ENV=${NODE_ENV:-production} INDIEKIT_DEBUG=${INDIEKIT_DEBUG:-0} && node scripts/preflight-production-security.mjs && node scripts/preflight-mongo-connection.mjs && node scripts/preflight-activitypub-rsa-key.mjs && node scripts/preflight-activitypub-profile-urls.mjs && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-activitypub-docloader-loglevel.mjs && node scripts/patch-endpoint-activitypub-private-url-docloader.mjs && node scripts/patch-endpoint-activitypub-migrate-alias-clear.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-posts-ai-fields.mjs && node scripts/patch-endpoint-posts-ai-cleanup.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.mjs && node scripts/patch-micropub-ai-block-resync.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-conversations-mastodon-disconnect.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], diff --git a/scripts/patch-micropub-ai-block-resync.mjs b/scripts/patch-micropub-ai-block-resync.mjs new file mode 100644 index 00000000..d1899363 --- /dev/null +++ b/scripts/patch-micropub-ai-block-resync.mjs @@ -0,0 +1,115 @@ +/** + * Patch @indiekit/endpoint-micropub/lib/post-data.js to detect stale AI block files. + * + * Problem: The v3 patch bug (supportsAiDisclosure always false) caused Indiekit to update + * MongoDB with AI field values (aiTextLevel, aiCodeLevel, etc.) but write the post file + * WITHOUT the ai: frontmatter block. Now when the user re-saves with the same AI values, + * Indiekit's isDeepStrictEqual check says "no properties changed" and skips the file write. + * The file remains stale (missing ai: block) even though MongoDB has the right data. + * + * Fix: Store an `_aiBlockVersion` field in MongoDB alongside each post. On update, if the + * stored version doesn't match the current patch version AND the post has AI fields, bypass + * the no-change check and force a file re-write. This triggers exactly once per affected + * post, then every subsequent no-change save correctly skips the write. + */ + +import { access, readFile, writeFile } from "node:fs/promises"; + +const AI_BLOCK_VERSION = "v4"; + +const candidates = [ + "node_modules/@indiekit/endpoint-micropub/lib/post-data.js", + "node_modules/@indiekit/indiekit/node_modules/@indiekit/endpoint-micropub/lib/post-data.js", +]; + +const marker = "AI block version resync patch"; + +// --- Old: simple destructuring that ignores _aiBlockVersion --- +const oldDestructure = `let { path: _originalPath, properties } = await this.read(application, url);`; + +const newDestructure = `let { path: _originalPath, properties, _aiBlockVersion: storedAiBlockVersion } = await this.read(application, url); // AI block version resync patch`; + +// --- Old: early return when no properties changed --- +const oldNoChange = ` // Return if no changes to template properties detected + const newProperties = getPostTemplateProperties(properties); + oldProperties = getPostTemplateProperties(oldProperties); + if (isDeepStrictEqual(newProperties, oldProperties)) { + return; + }`; + +const newNoChange = ` // Return if no changes to template properties detected + const newProperties = getPostTemplateProperties(properties); + oldProperties = getPostTemplateProperties(oldProperties); + if (isDeepStrictEqual(newProperties, oldProperties)) { + // AI block version resync patch: if post has AI fields and the file was written by an + // older patch version (or never written with the ai: block), force a one-time re-write. + const hasAiFields = + newProperties.aiTextLevel !== undefined || + newProperties.aiCodeLevel !== undefined; + const currentAiBlockVersion = "${AI_BLOCK_VERSION}"; + if (!hasAiFields || storedAiBlockVersion === currentAiBlockVersion) { + return; + } + // Fall through: force re-write to fix stale ai: block + }`; + +// --- Old: postData construction without _aiBlockVersion --- +const oldPostData = ` // Update data in posts collection + const postData = { _originalPath, path, properties };`; + +const newPostData = ` // Update data in posts collection + const postData = { _originalPath, path, properties, _aiBlockVersion: "${AI_BLOCK_VERSION}" }; // AI block version resync patch`; + +async function exists(filePath) { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +let checked = 0; +let patched = 0; + +for (const filePath of candidates) { + if (!(await exists(filePath))) { + continue; + } + + checked += 1; + const source = await readFile(filePath, "utf8"); + + if (source.includes(marker)) { + continue; + } + + if ( + !source.includes(oldDestructure) || + !source.includes(oldNoChange) || + !source.includes(oldPostData) + ) { + console.warn( + `[postinstall] Skipping micropub AI block resync patch for ${filePath}: upstream format changed`, + ); + continue; + } + + const updated = source + .replace(oldDestructure, newDestructure) + .replace(oldNoChange, newNoChange) + .replace(oldPostData, newPostData); + + await writeFile(filePath, updated, "utf8"); + patched += 1; +} + +if (checked === 0) { + console.log("[postinstall] No endpoint-micropub post-data.js found"); +} else if (patched === 0) { + console.log("[postinstall] micropub AI block resync patch already applied"); +} else { + console.log( + `[postinstall] Patched micropub AI block resync in ${patched} file(s)`, + ); +}