domma-cms 1.10.29 → 1.10.31

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.10.31] - 2026-10-26
4
+
5
+ - **Plugins can fetch user-chosen addresses safely.** `hooks.safeFetch(url, opts)` hands plugins the CMS's own outbound guard: public addresses only, the connection pinned to the checked address, redirects re-checked, ports 80 and 443, and the site's `outboundAllowPrivate` rule (ignored on hosted sites). Through the hook a plugin cannot pass in its own policy or resolver, and may raise the size, time and redirect limits only to 25 MB, 60 s and 5. It guards against plugin mistakes; on hosted sites plugin signing is what keeps out hostile code.
6
+
7
+ ## [1.10.30] - 2026-10-26
8
+
9
+ - Admin navigation keeps My Profile in the top account menu, removing its redundant System entry. Search defaults to Content, and existing System entries move there once while retaining saved settings and renamed folders.
10
+
3
11
  ## [1.10.29] - 2026-10-26
4
12
 
5
13
  - **Pro Tools open again on sites behind the Manager.** The admin's versioned-file check ran before the Manager handed a request to its site, so a site's request for a Tool's screen (Analytics Pro's dashboard, for one) was answered from the Manager's own copy of that plugin; where the two builds differ the fingerprint never matched and the screen 404'd. The check now runs after the plugins' hooks, so the site answers its own files.
package/CLAUDE.md CHANGED
@@ -132,7 +132,7 @@ Collection entry I/O is delegated to a storage adapter. Schema management is alw
132
132
  `$graphLookup`, server-state stages, a `$lookup` not into a site `cms_<slug>` collection) and `__proto__`/`constructor`/
133
133
  `prototype`. A new path that hands user input to `find`/`aggregate` must go through one of the two
134
134
  - **Outbound requests to user-chosen URLs go through `services/outbound.js` `safeFetch()`** (Actions' webhook step,
135
- form webhooks; plugins fetching a user's URL should too) - never bare `fetch`. Public addresses only after DNS, the
135
+ form webhooks; plugins use `hooks.safeFetch` - `pluginSafeFetch()`, which drops any policy/resolver they pass) - never bare `fetch`. Public addresses only after DNS, the
136
136
  connection pinned to the checked address, never this server's own addresses, redirects re-checked, ports 80/443.
137
137
  `server.json` `outboundAllowPrivate` (`true` or a list of hosts/addresses/CIDRs) lifts it for self-hosters and is
138
138
  ignored when `codePolicy.signedOnly()`. `wpImport/fetch.js` shares its address rules and pinned request
@@ -1 +1 @@
1
- import{isPathAllowed as F}from"./admin-scope.js";export const TOOLS_FOLDER_TEXT="Tools",MANAGE_PLUGINS_URL="#/plugins",MANAGE_PLUGINS_ITEM={text:"Marketplace",url:MANAGE_PLUGINS_URL,icon:"store",permission:"plugins"};export function stripItemByUrl(e,t){const n=[];for(const r of e||[]){if(!r||r.url===t)continue;const s={...r};Array.isArray(r.items)&&r.items.length&&(s.items=stripItemByUrl(r.items,t)),n.push(s)}return n}export function managePluginsItem(){return{...MANAGE_PLUGINS_ITEM}}export const TOOLS_FOLDER_KEY="tools";const E=["countUrl","core","permission"],c=e=>String(e??"").toLowerCase();export function slugify(e){return c(e).trim().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")}export function groupKeyFor(e,t){return`${c(e)}.${slugify(t)}`}export const GROUP_ICON="folder";const P=e=>e&&(e.type==="separator"||e.type==="spacer"||e.type==="theme-switcher"),A=e=>e&&!P(e)&&!e.url;function w(e){return(e||[]).filter(Boolean).map(t=>Array.isArray(t.items)?{...t,items:w(t.items)}:{...t})}function M(e,t,n=[]){for(const r of e||[]){if(!r)continue;if(t(r))return[...n,r];const s=M(r.items,t,[...n,r]);if(s)return s}return null}function I(e,t,n=[],r=[]){for(const s of e||[])s&&(t(s)&&r.push([...n,s]),I(s.items,t,[...n,s],r));return r}const U=e=>!e.some(t=>t.hidden||t.restricted);function $(e,t){for(const n of e||[]){if(t(n))return n;const r=$(n?.items,t);if(r)return r}return null}export function findFolder(e,t){if(!t)return null;const n=c(t);return $(e,r=>A(r)&&c(r.key)===n)||$(e,r=>A(r)&&!r.key&&c(r.text)===n)}function b(e,t){const n={...t,...e};for(const r of E)t[r]!==void 0&&(n[r]=t[r]);return!(Array.isArray(e.items)&&e.items.length)&&Array.isArray(t.items)&&(n.items=t.items),e.text||(n.text=t.text),!e.icon&&t.icon&&(n.icon=t.icon),delete n.ref,delete n.folder,delete n.group,n}function O(e,{persisted:t,orphan:n=!1,node:r=null}){const s=e?.item||{};return{persisted:t,orphan:n,source:s.source||(s.core?"Built in":""),folder:e?.folder||TOOLS_FOLDER_KEY,defaults:n?{text:null,icon:null}:{text:s.text||"",icon:s.icon||""},...n&&r&&{label:r.text||r.url},...(e?.group||e?.item?.group)&&{group:e.group||e.item.group}}}export function placeTools(e,t,{forEditor:n=!1}={}){const r=w(e),s=(t||[]).filter(o=>o&&o.item&&typeof o.item.text=="string"),m=new Map;for(const o of s)o.item.url&&!m.has(o.item.url)&&m.set(o.item.url,o);const y=new Set,d=o=>{const i=[];for(const u of o){const h=u.url?m.get(u.url):null;if(h&&!y.has(u.url)){y.add(u.url);const g=b(u,h.item);Array.isArray(u.items)&&u.items.length?g.items=d(u.items):n&&(g.items=[]),n&&(g.$tool=O(h,{persisted:!0})),i.push(g);continue}if(u.ref){if(!n)continue;const{ref:g,...f}=u;i.push({...f,items:d(u.items||[]),$tool:O(null,{persisted:!0,orphan:!0,node:u})});continue}Array.isArray(u.items)&&(u.items=d(u.items)),i.push(u)}return i},a=d(r),T=new Set,S=o=>{for(const i of o||[])i.hidden&&i.text&&!i.url&&!(Array.isArray(i.items)&&i.items.length)&&T.add(c(i.text)),S(i.items)};S(a);let x=null;const L=()=>{const o=findFolder(a,TOOLS_FOLDER_KEY);if(o)return o;if(!x){x={text:TOOLS_FOLDER_TEXT,key:TOOLS_FOLDER_KEY,icon:"tool",items:[],synthesised:!0};const i=a.findIndex(u=>["system","documentation"].includes(c(u?.key||u?.text)));a.splice(i===-1?a.length:i,0,x)}return x},l=new Set,k=(o,i)=>{const u=groupKeyFor(o.key||slugify(o.text),i),h=$(a,p=>A(p)&&c(p.key)===u)||(o.items||[]).find(p=>A(p)&&(!p.key||c(p.key)===u)&&c(p.text)===c(i));if(h)return h;const g={text:String(i),key:u,icon:GROUP_ICON,items:[],synthesised:!0},f=o.items.findIndex(p=>l.has(p));return o.items.splice(f===-1?o.items.length:f,0,g),g};for(const o of s){const{item:i}=o;if(i.url&&y.has(i.url))continue;i.url&&y.add(i.url);const u=T.has(c(i.text));if(u&&!n)continue;const h=b({},i);n&&(h.items=[],h.$tool=O(o,{persisted:!1}),u&&(h.hidden=!0));let f=(o.parent?findFolder(a,o.parent)||$(a,_=>!P(_)&&c(_.text)===c(o.parent)):null)||findFolder(a,o.folder||i.folder)||L();f.items=Array.isArray(f.items)?f.items:[];const p=typeof(o.group||i.group)=="string"?(o.group||i.group).trim():"";p&&slugify(p)&&(f=k(f,p),f.items=Array.isArray(f.items)?f.items:[]),f.items.push(h),l.add(h)}return a}export const LOCKED_TOOLS=[{folder:"overview",item:{text:"Dashboard",url:"#/",icon:"home"}},{folder:"content",item:{text:"Site menus",url:"#/menus",icon:"menu",permission:"menus"}},{folder:"system",item:{text:"Users",url:"#/users",icon:"users",permission:"users"}},{folder:"system",item:{text:"My Profile",url:"#/my-profile",icon:"user"}}];export function isLockedUrl(e){return LOCKED_TOOLS.some(t=>t.item.url===e)}export function ensureLockedTools(e,{leaveInHiddenFolders:t=!1}={}){const n=w(e);for(const{folder:r,item:s}of LOCKED_TOOLS){const m=I(n,l=>l?.url===s.url),y=m.find(l=>!l.some(k=>k.restricted));if(!y&&m.length)continue;const d=y?.at(-1);if(d){if(d.hidden&&delete d.hidden,!y.slice(0,-1).some(o=>o.hidden)||t)continue;const k=y.at(-2);k.items=k.items.filter(o=>o!==d)}const a=c(r),T=I(n,l=>A(l)&&c(l.key)===a).filter(U),S=I(n,l=>A(l)&&!l.key&&c(l.text)===a).filter(U),x=(T[0]||S[0])?.at(-1)||null,L=d||{...s};x?(x.items=Array.isArray(x.items)?x.items:[],x.items.push(L)):n.push(L)}return n}export function insertFoldersBeforeSystem(e,t){const n=(t||[]).filter(Boolean);if(!n.length)return e;const r=["system","documentation"],s=e.findIndex(m=>r.includes(c(m?.key||m?.text)));return s===-1?[...e,...n]:[...e.slice(0,s),...n,...e.slice(s)]}export function canAny(e,t){return t?!e||!e.length?!1:e.includes(t)?!0:["read","create","update","delete"].some(n=>e.includes(`${t}.${n}`)):!0}export function filterByPermission(e,t,n=null){const r=[];for(const s of e){if(s.hidden||s.permission&&!canAny(t,s.permission))continue;const m=typeof s.url=="string"?s.url:"";if(n&&m.startsWith("#/")&&!F(n,m.slice(1)))continue;const y=Array.isArray(s.items)&&s.items.length>0,d=y?filterByPermission(s.items,t,n):[];y&&!d.length&&!m||r.push({...s,items:d})}return r}export function sidebarStateKey(e,t=null){const n=String(e?.text||"").replace(/\s*\(\d+\)\s*$/,"").toLowerCase().replace(/\s+/g,"-"),r=String(e?.url||"").match(/^#\/projects\/([^/]+)/);let s="";if(r)try{s=`project-${decodeURIComponent(r[1])}.`}catch{s=`project-${r[1]}.`}return t?e?.key?`sidebar.expanded.key.${c(e.key)}`:`${t}/${s}${n}`:`sidebar.expanded.${s}${n}`}export function pruneEmptyToolsFolder(e){return(e||[]).filter(t=>A(t)?!(c(t.key)===TOOLS_FOLDER_KEY||!t.key&&t.text===TOOLS_FOLDER_TEXT)||Array.isArray(t.items)&&t.items.length>0:!0)}
1
+ import{isPathAllowed as F}from"./admin-scope.js";export const TOOLS_FOLDER_TEXT="Tools",MANAGE_PLUGINS_URL="#/plugins",MANAGE_PLUGINS_ITEM={text:"Marketplace",url:MANAGE_PLUGINS_URL,icon:"store",permission:"plugins"};export function stripItemByUrl(e,t){const n=[];for(const r of e||[]){if(!r||r.url===t)continue;const s={...r};Array.isArray(r.items)&&r.items.length&&(s.items=stripItemByUrl(r.items,t)),n.push(s)}return n}export function managePluginsItem(){return{...MANAGE_PLUGINS_ITEM}}export const TOOLS_FOLDER_KEY="tools";const E=["countUrl","core","permission"],c=e=>String(e??"").toLowerCase();export function slugify(e){return c(e).trim().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")}export function groupKeyFor(e,t){return`${c(e)}.${slugify(t)}`}export const GROUP_ICON="folder";const P=e=>e&&(e.type==="separator"||e.type==="spacer"||e.type==="theme-switcher"),A=e=>e&&!P(e)&&!e.url;function w(e){return(e||[]).filter(Boolean).map(t=>Array.isArray(t.items)?{...t,items:w(t.items)}:{...t})}function N(e,t,n=[]){for(const r of e||[]){if(!r)continue;if(t(r))return[...n,r];const s=N(r.items,t,[...n,r]);if(s)return s}return null}function I(e,t,n=[],r=[]){for(const s of e||[])s&&(t(s)&&r.push([...n,s]),I(s.items,t,[...n,s],r));return r}const U=e=>!e.some(t=>t.hidden||t.restricted);function $(e,t){for(const n of e||[]){if(t(n))return n;const r=$(n?.items,t);if(r)return r}return null}export function findFolder(e,t){if(!t)return null;const n=c(t);return $(e,r=>A(r)&&c(r.key)===n)||$(e,r=>A(r)&&!r.key&&c(r.text)===n)}function b(e,t){const n={...t,...e};for(const r of E)t[r]!==void 0&&(n[r]=t[r]);return!(Array.isArray(e.items)&&e.items.length)&&Array.isArray(t.items)&&(n.items=t.items),e.text||(n.text=t.text),!e.icon&&t.icon&&(n.icon=t.icon),delete n.ref,delete n.folder,delete n.group,n}function O(e,{persisted:t,orphan:n=!1,node:r=null}){const s=e?.item||{};return{persisted:t,orphan:n,source:s.source||(s.core?"Built in":""),folder:e?.folder||TOOLS_FOLDER_KEY,defaults:n?{text:null,icon:null}:{text:s.text||"",icon:s.icon||""},...n&&r&&{label:r.text||r.url},...(e?.group||e?.item?.group)&&{group:e.group||e.item.group}}}export function placeTools(e,t,{forEditor:n=!1}={}){const r=w(e),s=(t||[]).filter(o=>o&&o.item&&typeof o.item.text=="string"),d=new Map;for(const o of s)o.item.url&&!d.has(o.item.url)&&d.set(o.item.url,o);const y=new Set,m=o=>{const i=[];for(const u of o){const h=u.url?d.get(u.url):null;if(h&&!y.has(u.url)){y.add(u.url);const g=b(u,h.item);Array.isArray(u.items)&&u.items.length?g.items=m(u.items):n&&(g.items=[]),n&&(g.$tool=O(h,{persisted:!0})),i.push(g);continue}if(u.ref){if(!n)continue;const{ref:g,...f}=u;i.push({...f,items:m(u.items||[]),$tool:O(null,{persisted:!0,orphan:!0,node:u})});continue}Array.isArray(u.items)&&(u.items=m(u.items)),i.push(u)}return i},a=m(r),T=new Set,S=o=>{for(const i of o||[])i.hidden&&i.text&&!i.url&&!(Array.isArray(i.items)&&i.items.length)&&T.add(c(i.text)),S(i.items)};S(a);let x=null;const L=()=>{const o=findFolder(a,TOOLS_FOLDER_KEY);if(o)return o;if(!x){x={text:TOOLS_FOLDER_TEXT,key:TOOLS_FOLDER_KEY,icon:"tool",items:[],synthesised:!0};const i=a.findIndex(u=>["system","documentation"].includes(c(u?.key||u?.text)));a.splice(i===-1?a.length:i,0,x)}return x},l=new Set,k=(o,i)=>{const u=groupKeyFor(o.key||slugify(o.text),i),h=$(a,p=>A(p)&&c(p.key)===u)||(o.items||[]).find(p=>A(p)&&(!p.key||c(p.key)===u)&&c(p.text)===c(i));if(h)return h;const g={text:String(i),key:u,icon:GROUP_ICON,items:[],synthesised:!0},f=o.items.findIndex(p=>l.has(p));return o.items.splice(f===-1?o.items.length:f,0,g),g};for(const o of s){const{item:i}=o;if(i.url&&y.has(i.url))continue;i.url&&y.add(i.url);const u=T.has(c(i.text));if(u&&!n)continue;const h=b({},i);n&&(h.items=[],h.$tool=O(o,{persisted:!1}),u&&(h.hidden=!0));let f=(o.parent?findFolder(a,o.parent)||$(a,_=>!P(_)&&c(_.text)===c(o.parent)):null)||findFolder(a,o.folder||i.folder)||L();f.items=Array.isArray(f.items)?f.items:[];const p=typeof(o.group||i.group)=="string"?(o.group||i.group).trim():"";p&&slugify(p)&&(f=k(f,p),f.items=Array.isArray(f.items)?f.items:[]),f.items.push(h),l.add(h)}return a}export const LOCKED_TOOLS=[{folder:"overview",item:{text:"Dashboard",url:"#/",icon:"home"}},{folder:"content",item:{text:"Site menus",url:"#/menus",icon:"menu",permission:"menus"}},{folder:"system",item:{text:"Users",url:"#/users",icon:"users",permission:"users"}}];export function isLockedUrl(e){return LOCKED_TOOLS.some(t=>t.item.url===e)}export function ensureLockedTools(e,{leaveInHiddenFolders:t=!1}={}){const n=w(e);for(const{folder:r,item:s}of LOCKED_TOOLS){const d=I(n,l=>l?.url===s.url),y=d.find(l=>!l.some(k=>k.restricted));if(!y&&d.length)continue;const m=y?.at(-1);if(m){if(m.hidden&&delete m.hidden,!y.slice(0,-1).some(o=>o.hidden)||t)continue;const k=y.at(-2);k.items=k.items.filter(o=>o!==m)}const a=c(r),T=I(n,l=>A(l)&&c(l.key)===a).filter(U),S=I(n,l=>A(l)&&!l.key&&c(l.text)===a).filter(U),x=(T[0]||S[0])?.at(-1)||null,L=m||{...s};x?(x.items=Array.isArray(x.items)?x.items:[],x.items.push(L)):n.push(L)}return n}export function insertFoldersBeforeSystem(e,t){const n=(t||[]).filter(Boolean);if(!n.length)return e;const r=["system","documentation"],s=e.findIndex(d=>r.includes(c(d?.key||d?.text)));return s===-1?[...e,...n]:[...e.slice(0,s),...n,...e.slice(s)]}export function canAny(e,t){return t?!e||!e.length?!1:e.includes(t)?!0:["read","create","update","delete"].some(n=>e.includes(`${t}.${n}`)):!0}export function filterByPermission(e,t,n=null){const r=[];for(const s of e){if(s.hidden||s.permission&&!canAny(t,s.permission))continue;const d=typeof s.url=="string"?s.url:"";if(n&&d.startsWith("#/")&&!F(n,d.slice(1)))continue;const y=Array.isArray(s.items)&&s.items.length>0,m=y?filterByPermission(s.items,t,n):[];y&&!m.length&&!d||r.push({...s,items:m})}return r}export function sidebarStateKey(e,t=null){const n=String(e?.text||"").replace(/\s*\(\d+\)\s*$/,"").toLowerCase().replace(/\s+/g,"-"),r=String(e?.url||"").match(/^#\/projects\/([^/]+)/);let s="";if(r)try{s=`project-${decodeURIComponent(r[1])}.`}catch{s=`project-${r[1]}.`}return t?e?.key?`sidebar.expanded.key.${c(e.key)}`:`${t}/${s}${n}`:`sidebar.expanded.${s}${n}`}export function pruneEmptyToolsFolder(e){return(e||[]).filter(t=>A(t)?!(c(t.key)===TOOLS_FOLDER_KEY||!t.key&&t.text===TOOLS_FOLDER_TEXT)||Array.isArray(t.items)&&t.items.length>0:!0)}
@@ -90,6 +90,12 @@
90
90
  "url": "#/content-api",
91
91
  "icon": "file-text",
92
92
  "permission": "settings"
93
+ },
94
+ {
95
+ "text": "Search",
96
+ "url": "#/search",
97
+ "icon": "search",
98
+ "permission": "settings"
93
99
  }
94
100
  ],
95
101
  "key": "content"
@@ -173,22 +179,11 @@
173
179
  "icon": "layout",
174
180
  "permission": "layouts"
175
181
  },
176
- {
177
- "text": "My Profile",
178
- "url": "#/my-profile",
179
- "icon": "user"
180
- },
181
182
  {
182
183
  "text": "API Tokens",
183
184
  "url": "#/api-tokens",
184
185
  "icon": "key",
185
186
  "permission": "api-tokens"
186
- },
187
- {
188
- "text": "Search",
189
- "url": "#/search",
190
- "icon": "search",
191
- "permission": "settings"
192
187
  }
193
188
  ],
194
189
  "key": "system"
@@ -356,6 +356,7 @@ roles and notifications are stamped with it). The sections below cover the main
356
356
  | `registerNotificationSource({id, label, severity, defaultOn, every, atBoot, check, audience})` | A switchable notification source, optionally scheduled (`boot`, `15m`, `hourly`, `daily`) (0.80.0) |
357
357
  | `registerLoginCheck({id, phase, check, verify?})` | A sign-in check: `phase: 'before'` (e.g. lockout, before the password is checked) or `'after'` (e.g. two-factor, can ask one more question) (0.83.0) |
358
358
  | `registerPasswordCheck({id, check})` | A password rule applied wherever a password is set (0.83.0) |
359
+ | `safeFetch(url, {method, headers, body, timeoutMs, maxBytes, maxRedirects, truncate, signal})` | Fetch an address a site user chose, under the site's outbound rules (`services/outbound.js`): public addresses only, the connection pinned to the checked address, redirects re-checked, ports 80/443; `outboundAllowPrivate` applies on a self-hosted site and never on a signed-only one. Limits default to 1 MB / 15 s / 3 redirects and may be raised to 25 MB / 60 s / 5. Resolves `{ok, status, headers, buffer, text(), json(), url, truncated}`; a refusal rejects with `err.code === 'EREFUSED_SSRF'`. `body` is a string, Buffer or Uint8Array and `headers` a plain object or `Headers`; anything else rejects with a TypeError rather than going out empty. Use it instead of `fetch` for any user-chosen URL. Call it with `?.` - older cores lack it (1.10.31) |
359
360
  | `registerComponent({name, source})` | Add a Domma component from a `.dmc` source string, used on pages as `<dm-name>` or `[component]`; it is listed read-only in Components (0.92.2) |
360
361
 
361
362
  `onEnable` / `onDisable` exports receive `{fastify, services, hooks}` where `hooks` holds only the role and notification
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.10.29",
3
+ "version": "1.10.31",
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",
package/server/server.js CHANGED
@@ -338,7 +338,7 @@ try {
338
338
  item: {text: 'Theme', url: '#/theme', icon: 'droplet', permission: 'theme'}
339
339
  });
340
340
  await ensureSidebarItem({
341
- groupText: 'System',
341
+ groupText: 'Content',
342
342
  item: {text: 'Search', url: '#/search', icon: 'search', permission: 'settings'}
343
343
  });
344
344
  // Restructure Documentation: Tutorials → submenu, add Components reference set.
@@ -699,11 +699,10 @@ registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Roles',
699
699
  registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Users', url: '#/users', icon: 'users', permission: 'users', countUrl: '/api/users/_count', inventory: true}});
700
700
  registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Site Settings', url: '#/settings', icon: 'settings', permission: 'settings', countUrl: '/api/settings/_count'}});
701
701
  registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Effects', url: '#/effects', icon: 'sparkles', permission: 'settings', countUrl: '/api/effects/_count', inventory: true}});
702
- registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'My Profile', url: '#/my-profile', icon: 'user', countUrl: '/api/auth/me/_count'}});
703
702
  registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Layouts', url: '#/layouts', icon: 'layout', permission: 'layouts', countUrl: '/api/layouts/_count'}});
704
703
  registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Plugins', url: '#/plugins', icon: 'package', permission: 'plugins', countUrl: '/api/plugins/_count', inventory: true}});
705
704
  registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'API Tokens', url: '#/api-tokens', icon: 'key', permission: 'api-tokens', countUrl: '/api/api-tokens/_count'}});
706
- registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Search', url: '#/search', icon: 'search', permission: 'settings', countUrl: '/api/search/_count', inventory: true}});
705
+ registerSidebarItem({folder: 'content', item: {source: 'Built in', text: 'Search', url: '#/search', icon: 'search', permission: 'settings', countUrl: '/api/search/_count', inventory: true}});
707
706
  registerSidebarItem({folder: 'content', item: {source: 'Built in', text: 'Content API', url: '#/content-api', icon: 'file-text', permission: 'settings', countUrl: '/api/content-api/_count', inventory: true}});
708
707
  // Documentation pages added after the sidebar menu was seeded (0.93.1). The menu is
709
708
  // seeded once, so new pages reach existing sites only by registering here. The two
@@ -392,3 +392,62 @@ export async function safeFetch(url, opts = {}) {
392
392
  }
393
393
  throw new FetchRefused('Too many redirects.');
394
394
  }
395
+
396
+ /** The most a plugin may raise safeFetch's limits to. */
397
+ export const PLUGIN_FETCH_CAPS = Object.freeze({maxBytes: 25 * 1024 * 1024, timeoutMs: 60_000, maxRedirects: 5});
398
+
399
+ /** A Headers instance or a plain object, as a plain object; anything else is refused. */
400
+ function pluginHeaders(h) {
401
+ if (h == null) return undefined;
402
+ if (typeof Headers !== 'undefined' && h instanceof Headers) return Object.fromEntries(h.entries());
403
+ if (typeof h === 'object' && !Array.isArray(h)) return h;
404
+ throw new TypeError('safeFetch: headers must be an object or a Headers instance.');
405
+ }
406
+
407
+ /** A string, Buffer or byte array; anything else is refused rather than sent empty. */
408
+ function pluginBody(b) {
409
+ if (b == null) return undefined;
410
+ if (typeof b === 'string' || Buffer.isBuffer(b)) return b;
411
+ if (b instanceof Uint8Array) return Buffer.from(b.buffer, b.byteOffset, b.byteLength);
412
+ throw new TypeError('safeFetch: body must be a string, Buffer or Uint8Array.');
413
+ }
414
+
415
+ /**
416
+ * The options a plugin's safeFetch call keeps: the request's own. `policy`,
417
+ * `lookup` and `own` are dropped - the site's policy is always read fresh (so
418
+ * `outboundAllowPrivate` still applies on a self-hosted site and is still
419
+ * ignored on a signed-only one) and a plugin cannot hand in its own resolver.
420
+ * The limits may be raised up to PLUGIN_FETCH_CAPS, never past them.
421
+ *
422
+ * @param {object} [opts] - method, headers, body, timeoutMs, maxBytes, maxRedirects, truncate, signal
423
+ * @returns {object}
424
+ */
425
+ export function pluginFetchOptions(opts = {}) {
426
+ const o = opts && typeof opts === 'object' ? opts : {};
427
+ const capped = (key) => {
428
+ const n = Number(o[key]);
429
+ return o[key] != null && Number.isFinite(n) && n >= 0 ? Math.min(n, PLUGIN_FETCH_CAPS[key]) : undefined;
430
+ };
431
+ return {
432
+ method: typeof o.method === 'string' ? o.method : undefined,
433
+ headers: pluginHeaders(o.headers),
434
+ body: pluginBody(o.body),
435
+ timeoutMs: capped('timeoutMs'),
436
+ maxBytes: capped('maxBytes'),
437
+ maxRedirects: capped('maxRedirects'),
438
+ truncate: o.truncate === true,
439
+ signal: o.signal instanceof AbortSignal ? o.signal : undefined
440
+ };
441
+ }
442
+
443
+ /**
444
+ * `hooks.safeFetch(url, opts)` as plugins get it - see pluginFetchOptions().
445
+ * A refusal rejects with FetchRefused (`err.code === 'EREFUSED_SSRF'`); a body or
446
+ * headers of the wrong type reject with a TypeError.
447
+ *
448
+ * @param {string} url
449
+ * @param {object} [opts]
450
+ */
451
+ export async function pluginSafeFetch(url, opts = {}) {
452
+ return safeFetch(String(url), pluginFetchOptions(opts));
453
+ }
@@ -39,6 +39,7 @@ import {registerThemeProvider} from './themeSettings.js';
39
39
  import {classifyEntitlement, mayLoad} from './pluginEntitlement.js';
40
40
  import {notifierFor} from './notify.js';
41
41
  import {disableInstantCalls, enableInstantCalls, grantInstantCallsOnce} from './instantCalls.js';
42
+ import {pluginSafeFetch} from './outbound.js';
42
43
  import {disableDashboardPro, enableDashboardPro, registerWidget, unregisterWidgetsFrom} from './widgets.js';
43
44
  import {createCollection, getCollection} from './collections.js';
44
45
  import * as defaultRolesService from './roles.js';
@@ -570,7 +571,9 @@ export async function registerPlugins(fastify) {
570
571
  // Who may read a contact that is not their own (Contacts Pro's sharing) - hooks.js 7b.
571
572
  registerContactAccess: (fn) => registerContactAccess(manifest.name, fn),
572
573
  // Direct messages between team members (Messages) - hooks.js 7c; a call's "Add people" uses it.
573
- registerDirectMessenger: (fn) => registerDirectMessenger(manifest.name, fn)},
574
+ registerDirectMessenger: (fn) => registerDirectMessenger(manifest.name, fn),
575
+ // Fetching an address a site user chose (services/outbound.js) - the site's policy, never the plugin's.
576
+ safeFetch: pluginSafeFetch},
574
577
  settings,
575
578
  config: {}
576
579
  });
@@ -41,7 +41,8 @@ const SEED_ITEMS = [
41
41
  {text: 'Blocks', url: '#/blocks', icon: 'box', permission: 'pages'},
42
42
  {text: 'Components', url: '#/components', icon: 'component', permission: 'components'},
43
43
  {text: 'Views', url: '#/views', icon: 'eye', permission: 'views'},
44
- {text: 'Content API', url: '#/content-api', icon: 'file-text', permission: 'settings'}
44
+ {text: 'Content API', url: '#/content-api', icon: 'file-text', permission: 'settings'},
45
+ {text: 'Search', url: '#/search', icon: 'search', permission: 'settings'}
45
46
  ]
46
47
  },
47
48
  {
@@ -65,11 +66,9 @@ const SEED_ITEMS = [
65
66
  {text: 'Users', url: '#/users', icon: 'users', permission: 'users'},
66
67
  {text: 'Site Settings', url: '#/settings', icon: 'settings', permission: 'settings'},
67
68
  {text: 'Effects', url: '#/effects', icon: 'sparkles', permission: 'settings'},
68
- {text: 'Search', url: '#/search', icon: 'search', permission: 'settings'},
69
69
  {text: 'Layouts', url: '#/layouts', icon: 'layout', permission: 'layouts'},
70
70
  {text: 'Plugins', url: '#/plugins', icon: 'package', permission: 'plugins'},
71
- {text: 'API Tokens', url: '#/api-tokens', icon: 'key', permission: 'api-tokens'},
72
- {text: 'My Profile', url: '#/my-profile', icon: 'user'}
71
+ {text: 'API Tokens', url: '#/api-tokens', icon: 'key', permission: 'api-tokens'}
73
72
  ]
74
73
  },
75
74
  {
@@ -441,6 +440,35 @@ export async function ensureFolderKeys(opts = {}) {
441
440
  changed = true;
442
441
  }
443
442
 
443
+ // Account access lives in the top menu. Move Search out of the old
444
+ // System default once, preserving saved labels, permissions and folders.
445
+ if (!menu.meta?.accountSearchNavigation && items.length) {
446
+ const moving = [];
447
+ const removeProfile = nodes => nodes.filter(n => n?.url !== '#/my-profile').map(n => {
448
+ if (Array.isArray(n?.items)) n.items = removeProfile(n.items);
449
+ return n;
450
+ });
451
+ for (const folder of items.filter(n => isFolder(n) && n.key === 'system')) {
452
+ moving.push(...(folder.items || []).filter(n => n?.url === '#/search'));
453
+ folder.items = removeProfile((folder.items || []).filter(n => n?.url !== '#/search'));
454
+ }
455
+ const hasSearch = nodes => nodes.some(n => n?.url === '#/search'
456
+ || (Array.isArray(n?.items) && hasSearch(n.items)));
457
+ let content = items.find(n => isFolder(n) && n.key === 'content');
458
+ for (const item of moving) {
459
+ if (hasSearch(items)) break;
460
+ if (!content) {
461
+ content = {text: 'Content', key: 'content', icon: 'edit', items: []};
462
+ const index = items.findIndex(n => isFolder(n) && n.key === 'system');
463
+ items.splice(index === -1 ? items.length : index, 0, content);
464
+ }
465
+ if (!Array.isArray(content.items)) content.items = [];
466
+ content.items.push(item);
467
+ }
468
+ menu.meta = {...(menu.meta || {}), accountSearchNavigation: true};
469
+ changed = true;
470
+ }
471
+
444
472
  // An empty menu is left empty: the renderer's minimal fallback (Dashboard,
445
473
  // Menus) is what an admin needs to repair it, and a lone Tools folder would
446
474
  // stop that fallback from applying.