fix: force one-time ai: block resync for posts with stale files
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 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 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`.
|
- 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.
|
||||||
|
|||||||
+2
-2
@@ -4,8 +4,8 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"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",
|
"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-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",
|
"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"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
|
|||||||
@@ -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)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user