domma-cms 0.88.0 → 0.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/admin/css/admin.css +1 -1
  2. package/admin/js/lib/login-arrange.js +1 -1
  3. package/admin/js/templates/login.html +74 -52
  4. package/admin/js/views/login.js +1 -11
  5. package/package.json +1 -1
  6. package/plugins/blog/CLAUDE.md +6 -0
  7. package/plugins/blog/plugin.js +23 -0
  8. package/plugins/blog/plugin.json +2 -2
  9. package/plugins/blog/plugin.public.js +15 -2
  10. package/plugins/free-tier.lock.json +34 -14
  11. package/plugins/security/CLAUDE.md +7 -0
  12. package/plugins/security/admin/lib/resets.js +82 -0
  13. package/plugins/security/admin/lib/settings.js +1 -1
  14. package/plugins/security/admin/templates/security.html +3 -0
  15. package/plugins/security/admin/views/security.js +23 -10
  16. package/plugins/security/plugin.js +39 -1
  17. package/plugins/security/plugin.json +2 -2
  18. package/plugins/security/tests/security.test.js +34 -0
  19. package/plugins/seo/CLAUDE.md +48 -0
  20. package/plugins/seo/admin/lib/analyse.js +286 -0
  21. package/plugins/seo/admin/lib/redirects.js +163 -0
  22. package/plugins/seo/admin/lib/schema.js +113 -0
  23. package/plugins/seo/admin/lib/settings.js +141 -0
  24. package/plugins/seo/admin/templates/seo.html +224 -0
  25. package/plugins/seo/admin/views/seo.js +912 -0
  26. package/plugins/seo/config.js +7 -0
  27. package/plugins/seo/plugin.js +477 -0
  28. package/plugins/seo/plugin.json +64 -0
  29. package/plugins/seo/server/audit.js +106 -0
  30. package/plugins/seo/server/registry.js +57 -0
  31. package/plugins/seo/server/store.js +76 -0
  32. package/plugins/seo/tests/seo.test.js +192 -0
  33. package/server/routes/api/forms.js +3 -0
  34. package/server/routes/public.js +4 -3
  35. package/server/services/content.js +11 -0
  36. package/server/services/hooks.js +45 -1
  37. package/server/services/plugins.js +6 -0
  38. package/server/services/renderer.js +136 -55
  39. package/server/services/siteGitignore.js +114 -3
  40. package/server/services/sitemap.js +89 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.88.0",
3
+ "version": "0.89.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -165,6 +165,12 @@ with `?display=` and swaps the cards, since each display is different block mark
165
165
 
166
166
  ## Gotchas
167
167
 
168
+ - **In the sitemap (1.9.0, core 0.89+).** `registerSitemapSource('blog')` lists the index and every public post at the
169
+ live base path (a post with `seo.noindex` is left out); a non-GET that succeeds on /posts, /settings or /samples calls
170
+ `sitemapChanged()`. Post pages pass `baseUrl` (canonical/og:url - they had none before), `noindex` and
171
+ `item: {type: 'blog:post', id}` to renderBlogPage, so the SEO plugin knows what it is tagging. PUT /posts/:id MERGES
172
+ `seo` (the editor sends title + description only; the SEO Tool may have set image/noindex/focus).
173
+
168
174
  - **Public handlers must RETURN the reply.** `reply.type(...).send(html)` without `return` in an
169
175
  async handler loses the body under @fastify/compress - every browser got a 200 with nothing in it,
170
176
  while curl (no Accept-Encoding) saw the page. `tests/public.test.js` drives gzip and brotli.
@@ -291,6 +291,23 @@ export default async function blogPlugin(fastify, options) {
291
291
  registerEmbedShortcodes(options.hooks.registerShortcode, () => liveBase(settings));
292
292
  }
293
293
 
294
+ // The blog in /sitemap.xml (core 0.89+; feature-detected): the index and
295
+ // every public post, at the base path as it is saved now. A post marked
296
+ // "hide from search engines" (seo.noindex) is left out.
297
+ if (typeof options.hooks?.registerSitemapSource === 'function') {
298
+ options.hooks.registerSitemapSource({id: 'blog', label: 'Blog posts', list: async () => {
299
+ const base = liveBase(settings);
300
+ const posts = (await loadAll('blog-posts')).filter(p => isVisible(p) && p.seo?.noindex !== true);
301
+ return [{urlPath: base, title: 'Blog', changefreq: 'daily'},
302
+ ...posts.map(p => ({urlPath: `${base}/${encodeURIComponent(p.slug)}`, title: p.title || '',
303
+ lastmod: p.updatedAt || p.publishedAt || null, ...(safeImageUrl(p.featuredImage) && {image: safeImageUrl(p.featuredImage)})}))];
304
+ }});
305
+ // A saved post, publish or setting changes the list: the sitemap is rebuilt on its next request.
306
+ fastify.addHook('onResponse', async (request, reply) => {
307
+ if (request.method !== 'GET' && reply.statusCode < 400 && /\/(posts|settings|samples)\b/.test(request.url)) options.hooks.sitemapChanged?.();
308
+ });
309
+ }
310
+
294
311
  // The layouts are blocks; put the shipped ones where the Blocks tool can
295
312
  // edit them. Once each, never over a site's own copy (lib/render.js).
296
313
  try {
@@ -487,6 +504,12 @@ export default async function blogPlugin(fastify, options) {
487
504
  }
488
505
 
489
506
  const merged = { ...entry.data, ...postFields(request.body ?? {}, user) };
507
+ // `seo` is merged too: the editor sends title and description only, and
508
+ // must not wipe what the SEO Tool set (image, noindex, focus keyword...).
509
+ if (merged.seo && typeof merged.seo === 'object' && entry.data.seo && typeof entry.data.seo === 'object') {
510
+ merged.seo = {...entry.data.seo, ...merged.seo};
511
+ for (const [k, v] of Object.entries(merged.seo)) if (v === null) delete merged.seo[k];
512
+ }
490
513
 
491
514
  const updated = await updateEntry(POSTS_SLUG, id, merged);
492
515
  return reply.send(toPost(updated));
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "blog",
3
3
  "displayName": "Blog",
4
- "version": "1.8.0",
4
+ "version": "1.9.0",
5
5
  "tier": "free",
6
6
  "description": "Public-facing blog with posts, categories and comments - layouts and listings built from editable blocks.",
7
7
  "author": "Domma CMS",
8
- "date": "2026-09-26",
8
+ "date": "2026-09-27",
9
9
  "icon": "file-text",
10
10
  "admin": {
11
11
  "css": [
@@ -15,6 +15,7 @@
15
15
  import path from 'path';
16
16
  import {fileURLToPath} from 'url';
17
17
  import {renderBlogPage} from '../../server/services/renderer.js';
18
+ import {getConfig} from '../../server/config.js';
18
19
  import {byNewest, escapeHtml, matchBlogPath, pickDisplay, safeImageUrl} from './lib/layouts.js';
19
20
  import {catalogue, renderCards} from './lib/render.js';
20
21
  import {authorNamesFor, isVisible, liveBase, liveSettings, loadEntries, renderPostPage, resolveAuthorName} from './lib/page.js';
@@ -49,6 +50,15 @@ function buildPagination(page, totalPages, basePath, query = '') {
49
50
  return `<nav class="blog-pagination" aria-label="Pages">${parts.join('')}</nav>`;
50
51
  }
51
52
 
53
+ /**
54
+ * The site's own origin for canonical and og:url - as core's getBaseUrl():
55
+ * the Settings address when there is one, else the request's.
56
+ */
57
+ function originOf(request) {
58
+ const set = String(getConfig('site')?.baseUrl || '').trim().replace(/\/+$/, '');
59
+ return set || `${request.protocol}://${request.host || request.headers.host || request.hostname}`;
60
+ }
61
+
52
62
  /** JSON inside a <script> element, safe against `</script>` in any value. */
53
63
  function scriptJson(value) {
54
64
  return JSON.stringify(value).replace(/</g, '\\u003c');
@@ -121,7 +131,7 @@ ${items || ''}
121
131
  pagination: interactive ? '' : buildPagination(safePage, totalPages, base, requested),
122
132
  assetV: ASSET_V
123
133
  },
124
- {title, urlPath: request.url.split('?')[0]}
134
+ {title, urlPath: request.url.split('?')[0], baseUrl: originOf(request)}
125
135
  );
126
136
  return reply.type('text/html').send(html);
127
137
  }
@@ -215,7 +225,10 @@ ${items}
215
225
  ogImage: safeImageUrl(post.featuredImage),
216
226
  ogType: 'article',
217
227
  usedComponents: page.usedComponents,
218
- urlPath: `${base}/${slug}`
228
+ urlPath: `${base}/${slug}`,
229
+ baseUrl: originOf(request),
230
+ noindex: post.seo?.noindex === true,
231
+ item: {type: 'blog:post', id: post.id}
219
232
  }
220
233
  );
221
234
  return reply.type('text/html').send(html);
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
3
- "source": "dcms-marketplace@c07af11",
3
+ "source": "dcms-marketplace@466a485",
4
4
  "plugins": {
5
5
  "blog": {
6
- "version": "1.8.0",
6
+ "version": "1.9.0",
7
7
  "files": {
8
- "CLAUDE.md": "026438d4d93f5a637be16ac22d5131e8248e93d2df1bd9b30d5f1cfe357ba2cc",
8
+ "CLAUDE.md": "5f3260acc22f202ce3dd1a262a41d4a98016a71e05da8f95732dcd73b6e08e33",
9
9
  "admin/css/index.css": "6142a6b756e1218157842528830b1e84f592f0b84b7d5ce6449db421b6f4b039",
10
10
  "admin/templates/blog.html": "a6054c1febe764bb1edf5c9e466d44a9080d9c83ba5486437b7a8244acf5ba54",
11
11
  "admin/templates/categories.html": "ab7d6fc2fedd48876c585bc216a1532b3bdd78c946dee976c1171bfe4664feb6",
@@ -48,9 +48,9 @@
48
48
  "lib/page.js": "464face32871185368de096ed9a6aa635523bf319af6459308cfceb77d66f82c",
49
49
  "lib/render.js": "0e97c6a950bc962c8d8ea4b2f21d06cb73fbcdc738d8942d6935d01309a29b1d",
50
50
  "lib/samples.js": "e8cdb77ce22e43937707f6854d3a5cb722437dc584e22ac7b24480bef686cf85",
51
- "plugin.js": "db93fd831f6ff6762c6768d47e658830b6284b6f4a9efd2bbffaab5966ba0361",
52
- "plugin.json": "d6a254e9ec114cbc469e8e2b5a9ca33376c3f88e49c54d42ea589ac883e3dac3",
53
- "plugin.public.js": "8efc1ff3a2ead68d36fbd545f8ce590f600b293896ba6f4947418b1efcdf2947",
51
+ "plugin.js": "a6e4442400594116c43aab1db706f0b9c2953e85699aa458709752c6c27fd781",
52
+ "plugin.json": "234c8067a4bf9f214667b5c99e05933c3321f0b8b35815a76e8ac8eacbbda857",
53
+ "plugin.public.js": "ab23fadd5655fab8eea0e636a3e1f8ced78432d6da929059a4a147f0805ff3a4",
54
54
  "public/blog.css": "fd733e6b624190da1e70dc1b6613e2376efd7f7570a28fab311d81006d03d960",
55
55
  "public/blog.js": "d01c72a9834dcc7c5241923371f2b164ac30d20ac9f1a5c5dc5b71fa3cb09f08",
56
56
  "public/samples/aurora.svg": "feb29e2b46465913fc29de9eda221b00b1541f1fa3ea7120f0c1d1cd11a9a4f6",
@@ -82,27 +82,47 @@
82
82
  }
83
83
  },
84
84
  "security": {
85
- "version": "1.0.3",
85
+ "version": "1.1.0",
86
86
  "files": {
87
- "CLAUDE.md": "a9b7574236740de80513a535fb761127fa306f2177a5dda6e761213b1ad5b287",
87
+ "CLAUDE.md": "dc6f8755b8e6cdcac69cd928654395cde794e5b698032c132ce83dede24acead",
88
88
  "admin/lib/blocks.js": "5ef346b7de751518f8beb9212e3b89b270ae236560ae3d8a1f40d129c50918ff",
89
89
  "admin/lib/health.js": "3b233a3d29b2d69c333321602c13233b8221cfe1114aa03903739d9ae8ad65d5",
90
90
  "admin/lib/ip.js": "cf33be4fa2e2cad37bafd12ebec17f293891a88de402caad1bf9c9a57ce87680",
91
91
  "admin/lib/lockout.js": "c5e5e4b2934296f81ae8ed013725485bc269a0ef3cf3bdf3c16c53936635a1ee",
92
92
  "admin/lib/policy.js": "a4aedfd28f992e0e7759bbdac6344681eebe617c27849e91fa4405f6190f8462",
93
93
  "admin/lib/probes.js": "d56318903cb645a39f56e7762eddb7411941a72e1f446c1f9d79e35157f1d31b",
94
- "admin/lib/settings.js": "9b6c2fb3b54a1dcb642e3af6f584f93375f824fce67717a1ee56bebee9c35645",
95
- "admin/templates/security.html": "e96060b4849a1ef7691a918b9a77aff05ea3a2182a6093001454d04eb58c648d",
96
- "admin/views/security.js": "dcd38e07c5e83bbe013f5d764c52edef76d28ed4c3fe1cba8c5a59ce1b93e962",
94
+ "admin/lib/resets.js": "3724a2aec9f3c18ada96948aa6ce375ae3505bb7623a2c556e2e2670c5ca343c",
95
+ "admin/lib/settings.js": "b319953914f3a6f029ba991b36fe6fcd521956ea099578be42505f30e6c32faf",
96
+ "admin/templates/security.html": "df5798497ca1fd5ed200ce0ff921a055168ac7720f38b2db1bebf2c0efe71f04",
97
+ "admin/views/security.js": "280c1681f1aae6cf82a141e670d046c160c5f5fc3cb9de43f9d134a08b15a199",
97
98
  "bin/unlock.js": "5fcc3c0ef3ba3c424a366aa62896f51abad578183dfde283aa838a86549ca785",
98
99
  "config.js": "e6a00f7816637687d6a68ebbac65a71489246a926c28523c2a78d859e75b7ae0",
99
- "plugin.js": "3051c886f77b1b1e7f45873fbdcd78a5ba2d72caea97bde1235e84a298ab6114",
100
- "plugin.json": "bdcc88f0f5dec2d707806687ca80e1697255af49b4f30f43650e3824775deef8",
100
+ "plugin.js": "db5dc669a6a26bbe8756da59bdd04217e403be48012921b6d2b868cec57af489",
101
+ "plugin.json": "319d4333d03ed7d42ef9c2d0999befff7594343be963b6fee9c90bf7916ad142",
101
102
  "server/common-passwords.NOTICE": "3b148a49a569bfa24feaa7879db602da8f0607fa61ab6c52b7a62391fc7c009a",
102
103
  "server/common-passwords.txt": "68782d6a4a19a4768d5f15dd66bd534e7a33055cc755411e33f16d18c50fdcce",
103
104
  "server/facts.js": "25538a8d1cfe012688fceb8ec0d0dd7b43ac9e168430723d6fb731183701fb46",
104
105
  "server/state.js": "1319b322e94a4aec176948990b2d674b28dfece761eef4d5685df474037bfc4a",
105
- "tests/security.test.js": "951636a0e42ac8dbf78b69ca7e7e2adcd4e29d94a51998e738c3afa742c00866"
106
+ "tests/security.test.js": "f0cb70dcb28bc8b3de8d3b39064616296382c5b0f97bfd615ee8e24fe3b031bc"
107
+ }
108
+ },
109
+ "seo": {
110
+ "version": "1.0.0",
111
+ "files": {
112
+ "CLAUDE.md": "c4b326afeacded36d076f96ec3554b6cd875087ea5c84b29dffb144059292ff7",
113
+ "admin/lib/analyse.js": "368b9e20bc1eac957870272ee6909b5f7c09ecf0a2fc2c344fb5cd85473f18cd",
114
+ "admin/lib/redirects.js": "a506ca40b712c3d8087d6698397b900fe5005ae6094f80dbc3f3091d5d9b6819",
115
+ "admin/lib/schema.js": "4313e0ed528562ee8b85c18479663c8216101f892848b44426baefc8d7c1c7e8",
116
+ "admin/lib/settings.js": "94e76c2960376f0cc0570128a7226af177f5de7ee115357e37baba45b1e05e19",
117
+ "admin/templates/seo.html": "e8684d98bd0649c001d7395b75b2bc3c74234f947d3dbda508ff4ecb91e20d1c",
118
+ "admin/views/seo.js": "b37774a5df642bdbdb6f7fb89b12dbc6d45d06d2b70bd2de7faf4c3098c26189",
119
+ "config.js": "d90f4cfb10b2f44b528de37522d31fd01351a6ebbab95556b5dfa2820d69e1a8",
120
+ "plugin.js": "3dd88bec75535349e6a748bae90fc49e54e2781c4765b9c8ff02898795ef476d",
121
+ "plugin.json": "6fe2fb5d50d37cc9749cf84db7ed59c5557a52c1b38f63c8e7bc1744065cd7d6",
122
+ "server/audit.js": "f51fcf9403ad40fde9e9ba5f31e58ed95fc2a5c8514c921146f23ee1ca2e0bd3",
123
+ "server/registry.js": "9336614bff3ab8b4fe9d50de074a9633307ab41c46fa62113ca82379cfad90a6",
124
+ "server/store.js": "3584ec3efd8a2d0ba8fd5be7e56d97cca76b5b2358a7dda99e379c42be67253c",
125
+ "tests/seo.test.js": "bdc52f2a9f8f02aaa0cb907f76f1acf8ff887034172e18d9ae108d3675b68f1a"
106
126
  }
107
127
  },
108
128
  "shopping-cart": {
@@ -15,6 +15,13 @@ Ships with the engine (free tier, `make sync-free-tier`); switched ON when it fi
15
15
  MIT - see the NOTICE), not containing the name/email, not one or two characters repeated.
16
16
  - **Sign-in log**: `data/signins.jsonl`, kept `signinDays`; notifications (sources `security:lockouts`,
17
17
  `security:suspicious`, `security:probes` - the last off by default).
18
+ - **Reset requests** (1.1.0; core 0.88's `auth:resetRequested`): each "Forgot your password?" is a `kind: 'reset'`
19
+ line in the same log (`ok` = a link was sent, `reason` = the core's outcome - `admin/lib/resets.js` RESET_REASONS).
20
+ Sign-ins › Resets lists them; the overview tile counts 24 h. `security:resets` (setting `notify.resetProbing`, on) is
21
+ raised when one address asks about 3 different accounts within an hour - ALL the core allows it (forgot-password is
22
+ rate-limited to 3 an hour per address, so a higher threshold never trips) - or when 10 requests within an hour, from
23
+ any addresses, name accounts that do not exist (someone rotating addresses). Anything counting sign-ins must skip
24
+ `kind: 'reset'` lines - their `ok` is not a sign-in. Older engines never emit the event: nothing changes there.
18
25
  - **Health check**: `server/facts.js` gathers (users, engine vs npm latest, certificate via tls, security headers of the
19
26
  public home page, private paths fetched LOCALLY, TRUST_PROXY, bind, SMTP, licences); `admin/lib/health.js` judges.
20
27
  Unknown = 'info', never a pass. Cached 10 min; the sidebar badge (`/count`) = checks needing attention, red only
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Security - password reset requests (core's auth:resetRequested, 0.88). Pure.
3
+ *
4
+ * Each "Forgot your password?" goes into the sign-in log as `kind: 'reset'`.
5
+ * One address asking for resets of many different accounts is someone trying
6
+ * addresses, or trying to fill inboxes - that is what `recordReset` watches.
7
+ */
8
+
9
+ /** What became of a request, as the core reports it in `reason`. */
10
+ export const RESET_REASONS = {
11
+ sent: 'Link sent',
12
+ unknown: 'No such account',
13
+ inactive: 'Account switched off',
14
+ throttled: 'Held back - 3 an hour already',
15
+ 'no-smtp': 'Not sent - no mail server',
16
+ 'no-origin': 'Not sent - no Site URL',
17
+ failed: 'Email failed'
18
+ };
19
+
20
+ /**
21
+ * When it is worth telling someone. The core lets one address ask 3 times an hour
22
+ * (a route rate limit), and a person only ever asks about their own account - so
23
+ * one address using all 3 on different accounts is trying addresses. Someone
24
+ * rotating addresses gets past that, which is what the site-wide count of
25
+ * requests for accounts that do not exist is for.
26
+ */
27
+ export const RESET_PROBE = {threshold: 3, windowMin: 60};
28
+ export const RESET_UNKNOWN = {threshold: 10, windowMin: 60};
29
+
30
+ /**
31
+ * Note a reset request from an address and say whether it has just crossed the
32
+ * line: `threshold` different accounts within `windowMin`. Mutates `recent`
33
+ * (a Map of address -> [{at, email}]), dropping what is older than the window.
34
+ *
35
+ * @returns {{accounts: number, tripped: boolean}} tripped only on the request that reaches the threshold
36
+ */
37
+ export function recordReset(recent, ip, email, {now = Date.now(), threshold = RESET_PROBE.threshold, windowMin = RESET_PROBE.windowMin} = {}) {
38
+ if (!ip) return {accounts: 0, tripped: false};
39
+ const since = now - windowMin * 60_000;
40
+ const list = (recent.get(ip) || []).filter(r => r.at >= since);
41
+ const before = new Set(list.map(r => r.email)).size;
42
+ list.push({at: now, email: String(email || '').trim().toLowerCase()});
43
+ recent.set(ip, list);
44
+ const accounts = new Set(list.map(r => r.email)).size;
45
+ return {accounts, tripped: accounts >= threshold && before < threshold};
46
+ }
47
+
48
+ /**
49
+ * Note a request for an account that does not exist, from any address, and say
50
+ * whether the site has just reached `threshold` of them within `windowMin`.
51
+ * Mutates `stamps` (an array of times).
52
+ *
53
+ * @returns {{count: number, tripped: boolean}} tripped only on the request that reaches the threshold
54
+ */
55
+ export function recordUnknown(stamps, {now = Date.now(), threshold = RESET_UNKNOWN.threshold, windowMin = RESET_UNKNOWN.windowMin} = {}) {
56
+ const since = now - windowMin * 60_000;
57
+ const keep = stamps.filter(t => t >= since);
58
+ stamps.length = 0;
59
+ stamps.push(...keep, now);
60
+ return {count: stamps.length, tripped: stamps.length === threshold};
61
+ }
62
+
63
+ /** Forget site-wide stamps older than the window. Mutates `stamps`. */
64
+ export function pruneUnknown(stamps, {now = Date.now(), windowMin = RESET_UNKNOWN.windowMin} = {}) {
65
+ const keep = stamps.filter(t => t >= now - windowMin * 60_000);
66
+ stamps.length = 0;
67
+ stamps.push(...keep);
68
+ }
69
+
70
+ /** Forget addresses with nothing inside the window. */
71
+ export function pruneResets(recent, {now = Date.now(), windowMin = RESET_PROBE.windowMin} = {}) {
72
+ const since = now - windowMin * 60_000;
73
+ for (const [ip, list] of recent) {
74
+ const keep = list.filter(r => r.at >= since);
75
+ if (keep.length) recent.set(ip, keep); else recent.delete(ip);
76
+ }
77
+ }
78
+
79
+ /** One log entry as the Sign-ins list shows it. */
80
+ export function resetRowText(e) {
81
+ return {okText: 'Reset asked', tone: 'reset', note: RESET_REASONS[e.reason] || e.reason || (e.ok ? 'Link sent' : 'Not sent')};
82
+ }
@@ -11,7 +11,7 @@ export const DEFAULT_SETTINGS = {
11
11
  probes: {enabled: true, blockAfter: 5, withinMin: 10, blockForMin: 60, extra: [], allow: []},
12
12
  signinDays: 30,
13
13
  staleAdminDays: 90,
14
- notify: {lockouts: true, successAfterFails: true, probeBlocks: false}
14
+ notify: {lockouts: true, successAfterFails: true, probeBlocks: false, resetProbing: true}
15
15
  };
16
16
 
17
17
  const int = (v, min, max) => {
@@ -22,6 +22,7 @@
22
22
  <div class="scx-card scx-tile"><small>Failed sign-ins, 24h <span data-help="Failed sign-ins in the last 24 hours: a wrong password, an unknown email, a locked account."></span></small><strong data-bind-text="ov.value.failed">—</strong></div>
23
23
  <div class="scx-card scx-tile"><small>Blocked now <span data-help="Addresses blocked by a rule or for probing, and accounts or addresses locked after wrong passwords."></span></small><strong data-bind-text="ov.value.blocked">—</strong></div>
24
24
  <div class="scx-card scx-tile"><small>Probes refused today <span data-help="Requests for things no Domma site has - WordPress logins, .env files, database dumps. Scanners send them to every site they find; they are answered with an empty 'not found'."></span></small><strong data-bind-text="ov.value.probes">—</strong></div>
25
+ <div class="scx-card scx-tile"><small>Reset requests, 24h <span data-help="&quot;Forgot your password?&quot; requests in the last 24 hours, sent or not. Sign-ins › Resets lists them, with what happened and where from."></span></small><strong data-bind-text="ov.value.resets">—</strong></div>
25
26
  </div>
26
27
 
27
28
  <div class="scx-card">
@@ -41,6 +42,7 @@
41
42
  <button type="button" data-on-click="res_all" data-bind-class="result.value === 'all' && 'is-on'">All</button>
42
43
  <button type="button" data-on-click="res_failed" data-bind-class="result.value === 'failed' && 'is-on'">Failed</button>
43
44
  <button type="button" data-on-click="res_ok" data-bind-class="result.value === 'ok' && 'is-on'">Succeeded</button>
45
+ <button type="button" data-on-click="res_resets" data-bind-class="result.value === 'resets' && 'is-on'">Resets</button>
44
46
  </div>
45
47
  <select class="form-input scx-days" aria-label="Period" data-model="days.value">
46
48
  <option value="1">Last 24 hours</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="365">All kept</option>
@@ -150,6 +152,7 @@
150
152
  .scx-cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
151
153
  .scx-pill { display: inline-flex; align-items: center; justify-self: start; font-size: var(--dm-text-xs); font-weight: 600; padding: .05rem .5rem; border-radius: 999px; }
152
154
  .scx-pill[data-ok="true"] { color: var(--dm-success); background: color-mix(in srgb, var(--dm-success) 14%, transparent); }
155
+ .scx-pill[data-ok="reset"] { color: var(--dm-info); background: color-mix(in srgb, var(--dm-info) 14%, transparent); }
153
156
  .scx-pill[data-ok="false"] { color: var(--dm-danger); background: color-mix(in srgb, var(--dm-danger) 12%, transparent); }
154
157
  .scx-kebab { border: 0; background: none; cursor: pointer; font-size: var(--dm-text-lg); line-height: 1; color: var(--dm-text-muted); border-radius: var(--dm-radius-sm); padding: 0 .25rem; }
155
158
  .scx-kebab:hover { color: var(--dm-primary); }
@@ -10,6 +10,7 @@
10
10
  * only, and rows are addressed by index.
11
11
  * `#/plugins/security/<tab>` opens a tab. Pure logic: ../lib/*.js.
12
12
  */
13
+ import {resetRowText} from '../lib/resets.js';
13
14
  import {checkSettings, DEFAULT_SETTINGS, mergeDefaults} from '../lib/settings.js';
14
15
  import {ipRuleValid} from '../lib/ip.js';
15
16
 
@@ -152,7 +153,8 @@ export const securityView = {
152
153
  const asOf = M.computed(() => (health.value?.at ? `Checked ${new Date(health.value.at).toLocaleTimeString('en-GB', {hour: '2-digit', minute: '2-digit'})}` : ''));
153
154
  const ov = M.computed(() => {
154
155
  const o = overview.value;
155
- return o ? {failed: String(o.failed24h), blocked: String(o.blocked + o.locks), probes: String(o.today?.probes || 0)} : {failed: '—', blocked: '—', probes: '—'};
156
+ return o ? {failed: String(o.failed24h), blocked: String(o.blocked + o.locks), probes: String(o.today?.probes || 0), resets: String(o.resets24h ?? 0)}
157
+ : {failed: '—', blocked: '—', probes: '—', resets: '—'};
156
158
  });
157
159
  const blockedCount = M.computed(() => { const b = blocks.value; const n = b.rules.length + b.temp.length + b.locks.length; return n ? String(n) : ''; });
158
160
 
@@ -185,18 +187,25 @@ export const securityView = {
185
187
  const q = query.value.trim().toLowerCase();
186
188
  const r = result.value;
187
189
  const only = ipFilter.value;
188
- signinList = signins.value.filter(e => (r === 'all' || (r === 'ok') === Boolean(e.ok))
190
+ // A reset request is in the log too (kind 'reset'): only under All and Resets.
191
+ const isReset = (e) => e.kind === 'reset';
192
+ signinList = signins.value.filter(e => (r === 'all' || (r === 'resets' ? isReset(e) : !isReset(e) && (r === 'ok') === Boolean(e.ok)))
189
193
  && (!only || e.ip === only)
190
194
  && (!q || [e.email, e.ip, e.ua, e.reason].some(v => String(v || '').toLowerCase().includes(q))));
191
- signinRows.value = signinList.slice(0, 1000).map((e, i) => ({
192
- id: `s${i}`, when: when(e.at), ok: String(Boolean(e.ok)), okText: e.ok ? 'Signed in' : 'Failed',
193
- email: e.email || '—', ip: e.ip || '—', ua: e.ua || '',
194
- note: e.ok ? browserOf(e.ua) : `${REASONS[e.reason] || e.reason || 'Failed'} · ${browserOf(e.ua)}`
195
- }));
195
+ signinRows.value = signinList.slice(0, 1000).map((e, i) => {
196
+ const row = {id: `s${i}`, when: when(e.at), email: e.email || '—', ip: e.ip || '—', ua: e.ua || ''};
197
+ if (isReset(e)) {
198
+ const t = resetRowText(e);
199
+ return {...row, ok: t.tone, okText: t.okText, note: `${t.note} · ${browserOf(e.ua)}`};
200
+ }
201
+ return {...row, ok: String(Boolean(e.ok)), okText: e.ok ? 'Signed in' : 'Failed',
202
+ note: e.ok ? browserOf(e.ua) : `${REASONS[e.reason] || e.reason || 'Failed'} · ${browserOf(e.ua)}`};
203
+ });
196
204
  }));
197
205
  const signinText = M.computed(() => {
198
206
  const n = signinList.length + 0 * signinRows.value.length;
199
207
  const f = ipFilter.value ? ` from ${ipFilter.value}` : '';
208
+ if (result.value === 'resets') return `${n.toLocaleString('en-GB')} reset request${n === 1 ? '' : 's'}${f}`;
200
209
  return `${n.toLocaleString('en-GB')} sign-in${n === 1 ? '' : 's'}${f}`;
201
210
  });
202
211
  const signinEmpty = M.computed(() => (query.value.trim() || ipFilter.value ? 'No sign-ins match.' : 'No sign-ins in this period.'));
@@ -280,7 +289,9 @@ export const securityView = {
280
289
  }
281
290
  function exportCsv() {
282
291
  if (!signinList.length) { E.toast('Nothing to export.', {type: 'info'}); return; }
283
- const rows = signinList.map(e => ({time: e.at, result: e.ok ? 'signed in' : 'failed', email: e.email, address: e.ip, reason: e.ok ? '' : (REASONS[e.reason] || e.reason || ''), browser: e.ua}));
292
+ const rows = signinList.map(e => (e.kind === 'reset'
293
+ ? {time: e.at, result: 'reset requested', email: e.email, address: e.ip, reason: resetRowText(e).note, browser: e.ua}
294
+ : {time: e.at, result: e.ok ? 'signed in' : 'failed', email: e.email, address: e.ip, reason: e.ok ? '' : (REASONS[e.reason] || e.reason || ''), browser: e.ua}));
284
295
  const url = URL.createObjectURL(new Blob([toCsv(rows)], {type: 'text/csv;charset=utf-8'}));
285
296
  const a = Object.assign(document.createElement('a'), {href: url, download: `sign-ins-${location.hostname}-${new Date().toISOString().slice(0, 10)}.csv`});
286
297
  document.body.appendChild(a); a.click(); a.remove();
@@ -385,14 +396,14 @@ export const securityView = {
385
396
  probeOn: M.observable(s.probes.enabled), after: M.observable(String(s.probes.blockAfter)), within: M.observable(String(s.probes.withinMin)), forMin: M.observable(String(s.probes.blockForMin)),
386
397
  extra: M.observable(s.probes.extra.join('\n')), allow: M.observable(s.probes.allow.join('\n')),
387
398
  signinDays: M.observable(String(s.signinDays)), staleDays: M.observable(String(s.staleAdminDays)),
388
- nLock: M.observable(s.notify.lockouts), nAfter: M.observable(s.notify.successAfterFails), nProbe: M.observable(s.notify.probeBlocks)
399
+ nLock: M.observable(s.notify.lockouts), nAfter: M.observable(s.notify.successAfterFails), nProbe: M.observable(s.notify.probeBlocks), nReset: M.observable(s.notify.resetProbing)
389
400
  };
390
401
  const body = () => ({
391
402
  lockout: {enabled: f.lockOn.value, accountThreshold: f.acc.value, addressThreshold: f.addr.value, windowMin: f.win.value, baseMin: f.base.value, maxMin: f.max.value},
392
403
  policy: {enabled: f.polOn.value, minLength: f.minLen.value, blockCommon: f.common.value, blockPersonal: f.personal.value},
393
404
  probes: {enabled: f.probeOn.value, blockAfter: f.after.value, withinMin: f.within.value, blockForMin: f.forMin.value, extra: f.extra.value, allow: f.allow.value},
394
405
  signinDays: f.signinDays.value, staleAdminDays: f.staleDays.value,
395
- notify: {lockouts: f.nLock.value, successAfterFails: f.nAfter.value, probeBlocks: f.nProbe.value}
406
+ notify: {lockouts: f.nLock.value, successAfterFails: f.nAfter.value, probeBlocks: f.nProbe.value, resetProbing: f.nReset.value}
396
407
  });
397
408
  const opened = JSON.stringify(body());
398
409
  const saving = M.observable(false);
@@ -436,6 +447,7 @@ export const securityView = {
436
447
  ${sw('nLock', 'Notify when an account or address is locked')}
437
448
  ${sw('nAfter', 'Notify when someone signs in after many wrong passwords')}
438
449
  ${sw('nProbe', 'Notify when a scanner is blocked', 'Off by default: on a public site this happens daily.')}
450
+ ${sw('nReset', 'Notify when password resets look like someone trying addresses', 'One address asking about 3 different accounts within an hour (a person only asks about their own), or 10 requests in an hour for accounts that do not exist, from anywhere. Every request is in the sign-in log either way.')}
439
451
  </div></section>
440
452
  <section><h4>Locked out yourself?</h4>
441
453
  <p class="scx-note">Run this in the site's folder on the server - it clears lockouts and blocks, and the site notices within seconds:</p>
@@ -499,6 +511,7 @@ export const securityView = {
499
511
  const model = {tab, loading, error, checksOk, canManage, scoreText, scoreWidth, scoreTone, attentionText, asOf, ov, blockedCount, checks,
500
512
  result, days, query, signinRows, signinText, signinEmpty, lockRows, tempRows, ruleRows, newRule, newNote, youText};
501
513
  const methods = {
514
+ res_resets: () => { result.value = 'resets'; },
502
515
  tab_overview: () => setTab('overview'), tab_signins: () => setTab('signins'), tab_blocked: () => setTab('blocked'),
503
516
  res_all: () => { result.value = 'all'; }, res_failed: () => { result.value = 'failed'; }, res_ok: () => { result.value = 'ok'; },
504
517
  exportCsv,
@@ -31,6 +31,7 @@ import {checkSettings, mergeDefaults} from './admin/lib/settings.js';
31
31
  import {accountKey, activeLocks, addressKey, lockedUntil, pruneLockouts, recordFailure, recordSuccess, retryText} from './admin/lib/lockout.js';
32
32
  import {passwordProblems} from './admin/lib/policy.js';
33
33
  import {isProbe} from './admin/lib/probes.js';
34
+ import {pruneResets, pruneUnknown, recordReset, recordUnknown} from './admin/lib/resets.js';
34
35
  import {blockedBy, isTrusted, pruneBlocks, recordProbe} from './admin/lib/blocks.js';
35
36
  import {ipRuleValid, isLoopbackIp, plainIp} from './admin/lib/ip.js';
36
37
  import {getConfig, saveConfig} from '../../server/config.js';
@@ -72,6 +73,7 @@ async function security(fastify, options = {}) {
72
73
  const notify = (p) => { try { hooks.notify?.(p); } catch { /* notifications are a courtesy */ } };
73
74
  hooks.registerNotificationSource?.({id: 'lockouts', label: 'Sign-in lockouts', description: 'An account or address was locked after repeated wrong passwords.', severity: 'warning', audience: 'admins'});
74
75
  hooks.registerNotificationSource?.({id: 'suspicious', label: 'Suspicious sign-ins', description: 'Someone signed in after many wrong passwords.', severity: 'warning', audience: 'admins'});
76
+ hooks.registerNotificationSource?.({id: 'resets', label: 'Password reset probing', description: 'One address asked for password resets of many different accounts.', severity: 'warning', audience: 'admins'});
75
77
  hooks.registerNotificationSource?.({id: 'probes', label: 'Scanners blocked', description: 'An address probing for weaknesses was blocked.', severity: 'info', audience: 'admins', defaultOn: false});
76
78
 
77
79
  // ---------------------------------------------------------------------
@@ -161,6 +163,38 @@ async function security(fastify, options = {}) {
161
163
  }
162
164
  });
163
165
 
166
+ // Password reset requests (core 0.88's auth:resetRequested): into the
167
+ // sign-in log, and a notice when one address asks about many accounts.
168
+ const recentResets = new Map();
169
+ const unknownResets = [];
170
+ hooks.on?.('auth:resetRequested', (e) => {
171
+ try {
172
+ const ip = plainIp(e.ip);
173
+ state.logSignin({at: new Date().toISOString(), kind: 'reset', ok: Boolean(e.sent), email: e.email || '', userId: e.userId || null,
174
+ ip, ua: e.ua || '', reason: e.reason || ''});
175
+ if (isLoopbackIp(ip)) return;
176
+ if (e.reason === 'unknown') {
177
+ const many = recordUnknown(unknownResets);
178
+ if (many.tripped && cfg.notify.resetProbing !== false) {
179
+ notify({source: 'resets', severity: 'warning', title: `${many.count} password resets for accounts that do not exist`,
180
+ dedupeKey: 'reset-unknown',
181
+ body: `Within the hour, ${many.count} "Forgot your password?" requests named accounts this site does not have, from ${new Set(state.readSignins(Date.now() - 3_600_000).filter(x => x.kind === 'reset' && x.reason === 'unknown').map(x => x.ip)).size || 'several'} addresses - someone trying email addresses. The sign-in log (Resets) shows which.`,
182
+ link: '#/plugins/security/signins'});
183
+ }
184
+ }
185
+ if (!ip) return;
186
+ const {accounts, tripped} = recordReset(recentResets, ip, e.email);
187
+ if (tripped && cfg.notify.resetProbing !== false) {
188
+ notify({source: 'resets', severity: 'warning', title: `Password resets asked for ${accounts} accounts from ${ip}`,
189
+ dedupeKey: `reset-probe:${ip}`,
190
+ body: `${ip} asked for password resets of ${accounts} different accounts within the hour - a person only ever asks about their own, so this is someone trying email addresses. The log shows which; block the address if it is not someone you know.`,
191
+ link: '#/plugins/security/signins'});
192
+ }
193
+ } catch (err) {
194
+ log.warn(`[security] reset request not recorded: ${err.message}`);
195
+ }
196
+ });
197
+
164
198
  hooks.on?.('auth:loginSucceeded', (e) => {
165
199
  try {
166
200
  const ip = plainIp(e.ip);
@@ -217,12 +251,14 @@ async function security(fastify, options = {}) {
217
251
  routes.get('/overview', may('read'), async () => {
218
252
  const s = state.get();
219
253
  const now = Date.now();
220
- const day = state.readSignins(now - 86_400_000);
254
+ const all = state.readSignins(now - 86_400_000);
255
+ const day = all.filter(e => e.kind !== 'reset');
221
256
  const today = s.counters.day === new Date().toISOString().slice(0, 10) ? s.counters : {probes: 0, blocks: 0, failed: 0};
222
257
  return {
223
258
  today,
224
259
  failed24h: day.filter(e => !e.ok).length,
225
260
  succeeded24h: day.filter(e => e.ok).length,
261
+ resets24h: all.length - day.length,
226
262
  locks: activeLocks(s.lockouts, now).length,
227
263
  blocked: Object.values(s.temp).filter(t => t.until > now).length + s.rules.length,
228
264
  checks: canCheck,
@@ -302,6 +338,8 @@ async function security(fastify, options = {}) {
302
338
  // ---------------------------------------------------------------------
303
339
  const tidy = () => {
304
340
  state.change((s) => { pruneLockouts(s.lockouts, {windowMin: cfg.lockout.windowMin}); pruneBlocks(s); });
341
+ pruneResets(recentResets);
342
+ pruneUnknown(unknownResets);
305
343
  state.pruneSignins(cfg.signinDays);
306
344
  };
307
345
  const daily = setInterval(tidy, 6 * 3_600_000);
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "security",
3
3
  "displayName": "Security",
4
- "version": "1.0.3",
4
+ "version": "1.1.0",
5
5
  "tier": "free",
6
6
  "description": "Keeps a Domma CMS site safe: a health check of what is weak and how to fix it, sign-in lockout after repeated wrong passwords, password rules, a log of every sign-in, and blocking of the scanners that probe every site for WordPress, .env files and the like.",
7
7
  "author": "Domma CMS",
8
- "date": "2026-09-26",
8
+ "date": "2026-09-27",
9
9
  "icon": "shield",
10
10
  "permissions": [
11
11
  {
@@ -132,3 +132,37 @@ test('health: facts become checks; unknown is info, never a pass; the score coun
132
132
  assert.equal(unknown.checks.find(c => c.id === 'certificate').status, 'info');
133
133
  assert.equal(unknown.checks.find(c => c.id === 'headers').status, 'info');
134
134
  });
135
+
136
+ test('resets: one address asking about 3 different accounts in an hour trips once', async () => {
137
+ const {recordReset, pruneResets, resetRowText} = await import('../admin/lib/resets.js');
138
+ const recent = new Map();
139
+ const now = Date.now();
140
+ const at = (m) => ({now: now + m * MIN});
141
+ assert.deepEqual(recordReset(recent, '1.2.3.4', 'a@x.test', at(0)), {accounts: 1, tripped: false});
142
+ // The same account again is not another account.
143
+ assert.equal(recordReset(recent, '1.2.3.4', 'A@x.test', at(1)).accounts, 1);
144
+ assert.equal(recordReset(recent, '1.2.3.4', 'b@x.test', at(2)).tripped, false);
145
+ assert.deepEqual(recordReset(recent, '1.2.3.4', 'c@x.test', at(3)), {accounts: 3, tripped: true});
146
+ assert.equal(recordReset(recent, '1.2.3.4', 'd@x.test', at(4)).tripped, false, 'only on crossing');
147
+ // Another address is counted on its own; an hour later the first starts again.
148
+ assert.equal(recordReset(recent, '5.6.7.8', 'a@x.test', at(5)).accounts, 1);
149
+ assert.equal(recordReset(recent, '1.2.3.4', 'z@x.test', at(70)).accounts, 1);
150
+ pruneResets(recent, {now: now + 200 * MIN});
151
+ assert.equal(recent.size, 0);
152
+ assert.equal(recordReset(recent, '', 'a@x.test').tripped, false);
153
+ assert.equal(resetRowText({reason: 'no-smtp'}).note, 'Not sent - no mail server');
154
+ assert.equal(resetRowText({reason: 'odd'}).note, 'odd');
155
+ });
156
+
157
+ test('resets: 10 requests for unknown accounts in an hour, from anywhere, trip once', async () => {
158
+ const {recordUnknown, pruneUnknown} = await import('../admin/lib/resets.js');
159
+ const stamps = [];
160
+ const now = Date.now();
161
+ for (let i = 0; i < 9; i++) assert.equal(recordUnknown(stamps, {now: now + i * MIN}).tripped, false);
162
+ assert.deepEqual(recordUnknown(stamps, {now: now + 9 * MIN}), {count: 10, tripped: true});
163
+ assert.equal(recordUnknown(stamps, {now: now + 10 * MIN}).tripped, false, 'only on reaching it');
164
+ // An hour on, the early ones have gone.
165
+ assert.equal(recordUnknown(stamps, {now: now + 65 * MIN}).count, 7, 'minutes 5-10 still inside the hour, and this one');
166
+ pruneUnknown(stamps, {now: now + 200 * MIN});
167
+ assert.equal(stamps.length, 0);
168
+ });
@@ -0,0 +1,48 @@
1
+ # SEO (free) - AI Assistant Guide
2
+
3
+ How the site looks to search engines, and what to fix. Free tier (`make sync-free-tier` ships it with the engine).
4
+ SEO Pro (paid) is an ADD-ON (`requires: ["seo"]`, no supersedes) that plugs in through `server/registry.js`.
5
+ Social (paid, planned) will be its own plugin, sold with SEO Pro.
6
+
7
+ Needs core 0.89 for: `hooks.registerSitemapSource` / `sitemapChanged`, and the `seo:meta`, `sitemap:entries`,
8
+ `robots:lines` transforms (core `renderer.js` buildSeoMeta/renderSeoTags, `sitemap.js`). On an older engine the plugin
9
+ logs it, the screen says so, and the audit, editor and redirects still work (`hasCore` in plugin.js).
10
+
11
+ ## What it does
12
+ - **Audit** (`server/audit.js`): every sitemap URL (`listSitemapEntries`) plus published pages kept out of it, fetched
13
+ IN-PROCESS with `fastify.inject` (UA `DommaSEO/1 (audit)`, header `x-domma-seo: audit` - analytics / SEO Pro skip
14
+ those), judged by `admin/lib/analyse.js` (pure: `scanHtml` regex scanner - no DOM, `analyse`, `siteIssues`, `snippet`).
15
+ Every `audit.everyHours` (first run 3 min after start if stale), or POST /audit. Kept in `data/audit.json`.
16
+ - **Editor per URL** (GET/PUT /item): a core page is edited IN PLACE (`updatePage(path, {seo})`, empty -> null so core's
17
+ merge removes the key); anything else (a blog post) gets an override in `data/overrides.json`, applied by the
18
+ `seo:meta` transform (`admin/lib/schema.js applySeo`). Fields: title, description, image, noindex, focus.
19
+ - **Redirects** (`admin/lib/redirects.js`, pure): exact and "/prefix/*" rules, 301/302/307/308/410, query kept, no
20
+ loops (checkRule follows the chain), `onMoved` on core's `content:pageRenamed` re-aims older rules (no chains) and
21
+ drops a rule FROM the new address. Guard = global `onRequest` (fastify-plugin), GET/HEAD, never /api or /admin.
22
+ Hits counted in memory, saved each minute.
23
+ - **Sitemap/robots**: `sitemap:entries` drops excluded paths, switched-off sources, overridden noindex and redirected
24
+ addresses; `robots:lines` adds rules (plain rules into the `*` group; `User-agent:` blocks as their own groups) or
25
+ blocks everything. JSON-LD: Organization/LocalBusiness/Person on `/`, BlogPosting when `page.item.type === 'blog:post'`.
26
+
27
+ ## Data (plugins/seo/data - kept by updates, never bundled)
28
+ `redirects.json`, `overrides.json`, `audit.json` - in memory (the guard and transform run on every request), debounced
29
+ writes, re-read within ~3 s when changed on disk (`server/store.js`).
30
+
31
+ ## Permission
32
+ `seo` (read / manage), granted to admin. Routes: /overview /count /audit (GET, POST) /item (GET, PUT) /redirects
33
+ (GET, POST, PUT/:id, DELETE/:id - ids comma-separated) /redirects/import /redirects/test /sitemap /settings /extensions.
34
+
35
+ ## Extensions (SEO Pro)
36
+ `globalThis[Symbol.for('domma.seo')]` (`server/registry.js`): `register(owner, {label, tabs})`, `on('audit', fn)`,
37
+ `services` {settings, listEntries, auditList, fetchPage, scanHtml, analyse, audit {latest, summary, run},
38
+ redirects {list, match, add}, auditUserAgent}. Tabs: `{key, label, icon, module, help}`; `module` is a
39
+ `/plugins/<slug>/...` URL exporting `mount(root, ctx)` -> dispose; ctx = {kit, api, scope, here, openItem, reload, E, M, I}.
40
+
41
+ ## Admin
42
+ `admin/views/seo.js` + `admin/templates/seo.html` (prefix `sox-`): Pages (the audit + "Across the site"), Redirects,
43
+ Sitemap, extension tabs; page editor slideover (search result + share card previews, meters, checks); settings
44
+ slideover. Rows by index, text through data-bind-text only.
45
+
46
+ ## Tests
47
+ `tests/seo.test.js` (pure). Headless: throwaway CMS from a clean domma-cms worktree with plugins/seo + blog copied in;
48
+ puppeteer-core + ~/.cache/ms-playwright chromium_headless_shell for screenshots.