domma-cms 0.25.0 → 0.25.2

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/CLAUDE.md CHANGED
@@ -293,8 +293,8 @@ Collections are externally consumable at `/api/v1/:slug[/:id]` — a stable alia
293
293
 
294
294
  Tokens live in the `api-tokens` **preset collection** (always file-based and undeletable — both derived automatically from `PRESETS`). Each token is bound to one project (`data.project`, immutable) and only works on collections whose `resolveArtefactProject(schema)` matches; optional `scopes: [{collection, verbs[]}]` narrow it further. Service: `server/services/apiTokens.js` — SHA-256 hash stored, plaintext returned once by `createToken()`, in-memory validate cache invalidated via the `collection:entry*` hooks. Admin routes: `server/routes/api/api-tokens.js` (projects.js DI pattern). Admin UI: System → API Tokens (`admin/js/views/api-tokens.js`). Scaffolder recipes may declare `apiTokens: [{name, scopes?}]` — generated at apply time under the recipe's project, idempotent on re-apply, plaintext surfaced once in `created.apiTokens`.
295
295
 
296
- Permission family `api-tokens.{read,create,update,delete}`: existing roles are **not back-filled** (same gotcha as Menus/Projects), but `roles.js seed()` self-heals the level-0 root role with any missing registry resources at boot, so super-admins always see new families. `ensureSidebarItem()` in `sidebar-migration.js` appends the System → API Tokens entry on existing installs (no-op when the item's URL already exists anywhere in the persisted menu). Note: `admin/js/views/collection-editor.js` was de-minified (identifiers still mangled) so the API-access tab could gain the Token mode + read-fields input.
296
+ Permission family `api-tokens.{read,create,update,delete}`: custom roles are **not back-filled** (same gotcha as Menus/Projects), but `roles.js seed()` self-heals every BASE role against its seed definition at boot (append-only; root's seed = the full registry, and the base `admin` seed includes `api-tokens` + `api-endpoints` since 0.25.1). A seeded permission removed from a base role is re-added at boot use a custom role to run with less. `ensureSidebarItem()` in `sidebar-migration.js` appends the System → API Tokens entry on existing installs (no-op when the item's URL already exists anywhere in the persisted menu). Note: `admin/js/views/collection-editor.js` was de-minified (identifiers still mangled) so the API-access tab could gain the Token mode + read-fields input.
297
297
 
298
298
  ## Custom API endpoints (API Builder)
299
299
 
300
- Named endpoints over collection data at `/api/x/<project><path>` — e.g. `/api/x/world-cup/fixtures-day/:date`. Definitions are **data, never code**: entries in the `api-endpoints` preset collection binding a path (with `:param` segments) to a collection query — filter clauses whose values may embed `{{params.x}}` (required → 400 when missing) or `{{query.x}}` (optional → clause dropped), sort/limit, a read field allowlist, `mode: list|single`, and `auth: public|token|<role>`. One catch-all route (`server/routes/api/endpoints-public.js`, `fastify.get('/x/*')`) matches at runtime via the compiled registry in `server/services/apiEndpoints.js` (static segments beat `:param`; registry invalidated via the `collection:entry*` hooks + own mutations). Auth reuses `checkPublicAccess` from `routes/api/collections.js` (now exported) with a synthesized schema, so token project binding/scopes and the field allowlist are the same battle-tested path as `/api/v1`. Public-auth responses are cached with tags `collection:<data collection>` + `collection:api-endpoints` — both invalidated automatically by the service layer on entry/definition writes. Endpoints are the 10th project artefact type (`apis` in `getArtefactsForProject`; skipped by untag-all since the project IS the URL namespace; a project with endpoints refuses deletion). Admin: **Data → API Builder** (`admin/js/views/api-endpoints.js` list, `api-endpoint-editor.js` builder with try-it console — raw `fetch`, tests the SAVED definition only). Validation refuses system-managed collections (would leak `api-tokens` hashes), duplicate path shapes per project (`/a/:x` ≡ `/a/:y` → 409), and unknown roles/ops. Scaffolder block: `apiEndpoints: [{path, collection, filter, ...}]`, idempotent by path shape. Permission family `api-endpoints.*` — same back-fill gotcha as api-tokens.
300
+ Named endpoints over collection data at `/api/x/<project><path>` — e.g. `/api/x/world-cup/fixtures-day/:date`. Definitions are **data, never code**: entries in the `api-endpoints` preset collection binding a path (with `:param` segments) to a collection query — filter clauses whose values may embed `{{params.x}}` (required → 400 when missing) or `{{query.x}}` (optional → clause dropped), sort/limit, a read field allowlist, `mode: list|single`, and `auth: public|token|<role>`. One catch-all route (`server/routes/api/endpoints-public.js`, `fastify.get('/x/*')`) matches at runtime via the compiled registry in `server/services/apiEndpoints.js` (static segments beat `:param`; registry invalidated via the `collection:entry*` hooks + own mutations). Auth reuses `checkPublicAccess` from `routes/api/collections.js` (now exported) with a synthesized schema, so token project binding/scopes and the field allowlist are the same battle-tested path as `/api/v1`. Public-auth responses are cached with tags `collection:<data collection>` + `collection:api-endpoints` — both invalidated automatically by the service layer on entry/definition writes. Endpoints are the 10th project artefact type (`apis` in `getArtefactsForProject`; skipped by untag-all since the project IS the URL namespace; a project with endpoints refuses deletion). Admin: **Data → API Builder** (`admin/js/views/api-endpoints.js` list, `api-endpoint-editor.js` builder with try-it console — raw `fetch`, tests the SAVED definition only). Validation refuses system-managed collections (would leak `api-tokens` hashes), duplicate path shapes per project (`/a/:x` ≡ `/a/:y` → 409), and unknown roles/ops. Scaffolder block: `apiEndpoints: [{path, collection, filter, ...}]`, idempotent by path shape. Permission family `api-endpoints.*` — in the base super-admin/admin seeds (boot self-heal); custom roles need a manual grant. Since 0.25.1 an endpoint may only expose collections resolving to **its own project or core** (server 400s cross-project bindings; routes 403 collections outside the caller's scope; the builder's picker filters to match).
@@ -1 +1 @@
1
- import{api as h}from"../api.js";const z=[["_eq","equals"],["_ne","not equal"],["_gt",">"],["_gte",">="],["_lt","<"],["_lte","<="],["_in","in list"],["_nin","not in list"],["_contains","contains"],["_starts","starts with"],["_ends","ends with"],["_exists","exists"]],Z=["id","createdBy"];function G(p){return String(p??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}function K(p){for(const[f]of z)if(p.endsWith(f))return{field:p.slice(0,-f.length),op:f};return{field:p,op:"_eq"}}export const apiEndpointEditorView={templateUrl:"/admin/js/templates/api-endpoint-editor.html",async onMount(p){const f=p.get(0),n=e=>f.querySelector("#"+e)||document.getElementById(e),T=location.hash.split("?")[0].match(/\/api-endpoints\/edit\/([^/?#]+)/),v=T?decodeURIComponent(T[1]):null;n("ep-title").textContent=v?"Edit API Endpoint":"New API Endpoint";const[B,D,F,i]=await Promise.all([h.projects.list().catch(()=>[]),h.collections.list().catch(()=>[]),h.get("/collections/roles/entries?limit=100").catch(()=>({entries:[]})),v?h.apiEndpoints.get(v).catch(()=>null):Promise.resolve(null)]);if(v&&!i){E.toast("Endpoint not found.",{type:"error"}),location.hash="#/api-endpoints";return}const U=D.filter(e=>!e.systemManaged&&!e.preset),J=(F.entries||[]).map(e=>e.data?.name).filter(Boolean),y=n("endpoint-project");for(const e of B){const t=document.createElement("option");t.value=e.slug,t.textContent=`${e.name} (${e.slug})`,y.appendChild(t)}const C=n("ep-collection");for(const e of U){const t=document.createElement("option");t.value=e.slug,t.textContent=`${e.title} (${e.slug})`,C.appendChild(t)}const x=n("ep-auth");for(const[e,t]of[["public","Public \u2014 no credentials"],["token","Token \u2014 project API token"]]){const a=document.createElement("option");a.value=e,a.textContent=t,x.appendChild(a)}for(const e of J){const t=document.createElement("option");t.value=e,t.textContent=`Role: ${e}`,x.appendChild(t)}let b=[];async function q(){const e=C.value;if(b=[],e)try{b=((await h.collections.get(e))?.fields||[]).map(a=>a.name).filter(Boolean)}catch{}j();for(const t of k.querySelectorAll(".ep-filter-row"))N(t.querySelector(".ep-f-field"));O()}function A(){return[...b,...Z]}function j(e=[]){const t=n("ep-fields-list");t.textContent="";for(const a of b){const o=document.createElement("label");o.style.cssText="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.9rem;";const l=document.createElement("input");l.type="checkbox",l.className="ep-field-cb",l.value=a,l.checked=e.includes(a),l.addEventListener("change",d),o.appendChild(l),o.appendChild(document.createTextNode(a)),t.appendChild(o)}if(!b.length){const a=document.createElement("p");a.className="text-muted",a.style.cssText="font-size:.85rem;margin:0;",a.textContent="No fields found on this collection.",t.appendChild(a)}}function O(e){const t=n("ep-sort"),a=e!==void 0?e:t.value;t.textContent="";for(const o of["createdAt",...A()]){const l=document.createElement("option");l.value=o==="createdAt"?"":o,l.textContent=o==="createdAt"?"createdAt (default)":o,t.appendChild(l)}t.value=a||""}function N(e,t){const a=t!==void 0?t:e.value;e.textContent="";for(const l of A()){const s=document.createElement("option");s.value=l,s.textContent=l,e.appendChild(s)}const o=document.createElement("option");if(o.value="__custom__",o.textContent="custom dot-path\u2026",e.appendChild(o),a&&[...e.options].some(l=>l.value===a))e.value=a;else if(a){const l=document.createElement("option");l.value=a,l.textContent=a,e.insertBefore(l,o),e.value=a}}const k=n("ep-filter-rows");function $(e="",t="_eq",a=""){const o=document.createElement("div");o.className="ep-filter-row",o.style.cssText="display:grid;grid-template-columns:2fr 1.2fr 2fr auto;gap:.5rem;margin-bottom:.5rem;align-items:center;";const l=document.createElement("select");l.className="form-input ep-f-field",N(l,e||void 0),l.addEventListener("change",()=>{if(l.value==="__custom__"){const m=prompt("Dot-path field (e.g. address.city):","");m&&m.trim()?N(l,m.trim()):l.value=A()[0]||""}d()});const s=document.createElement("select");s.className="form-input ep-f-op";for(const[m,_]of z){const g=document.createElement("option");g.value=m,g.textContent=_,s.appendChild(g)}s.value=t,s.addEventListener("change",d);const c=document.createElement("input");c.type="text",c.className="form-input ep-f-value",c.placeholder="e.g. {{params.date}} or {{query.team}} or a literal",c.value=a,c.addEventListener("input",d);const r=document.createElement("button");r.className="btn btn-sm btn-ghost",r.innerHTML='<span data-icon="trash"></span>',r.addEventListener("click",()=>{o.remove(),d()}),o.appendChild(l),o.appendChild(s),o.appendChild(c),o.appendChild(r),k.appendChild(o),Domma.icons.scan(o)}function M(){const e={};for(const t of k.querySelectorAll(".ep-filter-row")){const a=t.querySelector(".ep-f-field").value,o=t.querySelector(".ep-f-op").value,l=t.querySelector(".ep-f-value").value;!a||a==="__custom__"||(e[o==="_eq"?a:a+o]=l)}return e}function S(){return{name:n("ep-name").value.trim(),project:y.value,path:n("ep-path").value.trim(),collection:C.value,auth:x.value,mode:n("ep-mode").value,filter:M(),sort:n("ep-sort").value||null,order:n("ep-order").value,limit:parseInt(n("ep-limit").value,10)||0,fields:[...f.querySelectorAll(".ep-field-cb:checked")].map(e=>e.value),enabled:n("ep-enabled").checked}}let w=null,u=null;function P(){return!w||JSON.stringify(S())!==w}function d(){R(),L()}function R(){n("ep-url-preview").textContent=`/api/x/${y.value||"<project>"}${n("ep-path").value||"/\u2026"}`}function H(e){return(e.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g)||[]).map(t=>t.slice(1))}function L(){const e=P();n("tryit-dirty-note").style.display=e?"":"none",n("tryit-send").disabled=e,n("tryit-token-group").style.display=u?.auth==="token"?"":"none";const t=n("tryit-params"),a={};for(const o of t.querySelectorAll("input"))a[o.dataset.param]=o.value;t.textContent="";for(const o of H(u?.path||"")){const l=document.createElement("div");l.className="form-group";const s=document.createElement("label");s.className="form-label",s.textContent=`:${o}`;const c=document.createElement("input");c.type="text",c.className="form-input",c.dataset.param=o,c.value=a[o]||"",l.appendChild(s),l.appendChild(c),t.appendChild(l)}}let I="";async function W(){if(!u)return;let e=u.path;for(const r of n("tryit-params").querySelectorAll("input"))e=e.replace(":"+r.dataset.param,encodeURIComponent(r.value));const t=n("tryit-query").value.trim(),a=`/api/x/${u.project}${e}${t?"?"+t:""}`,o={},l=n("tryit-token").value.trim();u.auth==="token"&&l&&(o.Authorization=`Bearer ${l}`);const s=location.origin+a;I=`curl ${o.Authorization?`-H 'Authorization: ${o.Authorization}' `:""}'${s}'`;const c=n("tryit-send");c.disabled=!0;try{const r=await fetch(a,{headers:o}),m=await r.text();let _=m;try{_=JSON.stringify(JSON.parse(m),null,2)}catch{}n("tryit-result").style.display="";const g=n("tryit-status");g.textContent=`${r.status} ${r.statusText}`,g.className="badge "+(r.ok?"badge-success":r.status>=500?"badge-danger":"badge-warning"),n("tryit-body").textContent=_}catch(r){n("tryit-result").style.display="",n("tryit-status").textContent="Network error",n("tryit-status").className="badge badge-danger",n("tryit-body").textContent=String(r.message||r)}finally{c.disabled=P()}}if(i){n("ep-name").value=i.name||"",y.value=i.project,y.disabled=!0,n("ep-path").value=i.path||"",C.value=i.collection,x.value=i.auth||"public",n("ep-mode").value=i.mode||"list",n("ep-order").value=i.order||"desc",n("ep-limit").value=i.limit??50,n("ep-enabled").checked=i.enabled!==!1,await q(),j(i.fields||[]),O(i.sort||"");for(const[e,t]of Object.entries(i.filter||{})){const{field:a,op:o}=K(e);$(a,o,String(t))}u=i,w=JSON.stringify(S())}else await q(),$();R(),L();for(const e of["ep-name","ep-path","ep-limit"])n(e).addEventListener("input",d);for(const e of["ep-mode","ep-order","ep-sort"])n(e).addEventListener("change",d);n("ep-enabled").addEventListener("change",d),y.addEventListener("change",d),x.addEventListener("change",d),C.addEventListener("change",async()=>{await q(),d()}),n("ep-add-filter").addEventListener("click",()=>{$(),d()}),n("tryit-send").addEventListener("click",W),n("tryit-copy-curl").addEventListener("click",async()=>{if(!I){E.toast("Send a request first.",{type:"info"});return}try{await navigator.clipboard.writeText(I),E.toast("curl command copied.",{type:"success"})}catch{E.toast("Copy failed.",{type:"error"})}}),n("ep-save").addEventListener("click",async()=>{const e=S();if(!e.name){E.toast("A name is required.",{type:"error"});return}if(!e.path.startsWith("/")){E.toast('The path must start with "/".',{type:"error"});return}try{if(v)u=await h.apiEndpoints.update(v,e),w=JSON.stringify(S()),E.toast("Endpoint saved.",{type:"success"}),L();else{const t=await h.apiEndpoints.create(e);E.toast("Endpoint created.",{type:"success"}),location.hash="#/api-endpoints/edit/"+encodeURIComponent(t.id)}}catch(t){E.toast(`Save failed: ${t.message||t}`,{type:"error"})}}),Domma.icons.scan(f)}};
1
+ import{api as y}from"../api.js";const D=[["_eq","equals"],["_ne","not equal"],["_gt",">"],["_gte",">="],["_lt","<"],["_lte","<="],["_in","in list"],["_nin","not in list"],["_contains","contains"],["_starts","starts with"],["_ends","ends with"],["_exists","exists"]],V=["id","createdBy"];function X(u){return String(u??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}function G(u){for(const[h]of D)if(u.endsWith(h))return{field:u.slice(0,-h.length),op:h};return{field:u,op:"_eq"}}export const apiEndpointEditorView={templateUrl:"/admin/js/templates/api-endpoint-editor.html",async onMount(u){const h=u.get(0),o=e=>h.querySelector("#"+e)||document.getElementById(e),O=location.hash.split("?")[0].match(/\/api-endpoints\/edit\/([^/?#]+)/),g=O?decodeURIComponent(O[1]):null;o("ep-title").textContent=g?"Edit API Endpoint":"New API Endpoint";const[F,U,J,r]=await Promise.all([y.projects.list().catch(()=>[]),y.collections.list().catch(()=>[]),y.get("/collections/roles/entries?limit=100").catch(()=>({entries:[]})),g?y.apiEndpoints.get(g).catch(()=>null):Promise.resolve(null)]);if(g&&!r){E.toast("Endpoint not found.",{type:"error"}),location.hash="#/api-endpoints";return}const M=U.filter(e=>!e.systemManaged&&!e.preset),H=(J.entries||[]).map(e=>e.data?.name).filter(Boolean),v=o("endpoint-project");for(const e of F){const t=document.createElement("option");t.value=e.slug,t.textContent=`${e.name} (${e.slug})`,v.appendChild(t)}const p=o("ep-collection");function T(e){return e.meta&&e.meta.project||"core"}function N(e){const t=e!==void 0?e:p.value,l=v.value,a=M.filter(n=>{const i=T(n);return i===l||i==="core"});p.textContent="";for(const n of a){const i=document.createElement("option");i.value=n.slug,i.textContent=`${n.title} (${n.slug})${T(n)==="core"&&l!=="core"?" \u2014 core":""}`,p.appendChild(i)}if(!a.length){const n=document.createElement("option");n.value="",n.textContent="No collections available in this project",p.appendChild(n)}t&&[...p.options].some(n=>n.value===t)&&(p.value=t)}N();const x=o("ep-auth");for(const[e,t]of[["public","Public \u2014 no credentials"],["token","Token \u2014 project API token"]]){const l=document.createElement("option");l.value=e,l.textContent=t,x.appendChild(l)}for(const e of H){const t=document.createElement("option");t.value=e,t.textContent=`Role: ${e}`,x.appendChild(t)}let b=[];async function S(){const e=p.value;if(b=[],e)try{b=((await y.collections.get(e))?.fields||[]).map(l=>l.name).filter(Boolean)}catch{}P();for(const t of $.querySelectorAll(".ep-filter-row"))k(t.querySelector(".ep-f-field"));R()}function A(){return[...b,...V]}function P(e=[]){const t=o("ep-fields-list");t.textContent="";for(const l of b){const a=document.createElement("label");a.style.cssText="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.9rem;";const n=document.createElement("input");n.type="checkbox",n.className="ep-field-cb",n.value=l,n.checked=e.includes(l),n.addEventListener("change",d),a.appendChild(n),a.appendChild(document.createTextNode(l)),t.appendChild(a)}if(!b.length){const l=document.createElement("p");l.className="text-muted",l.style.cssText="font-size:.85rem;margin:0;",l.textContent="No fields found on this collection.",t.appendChild(l)}}function R(e){const t=o("ep-sort"),l=e!==void 0?e:t.value;t.textContent="";for(const a of["createdAt",...A()]){const n=document.createElement("option");n.value=a==="createdAt"?"":a,n.textContent=a==="createdAt"?"createdAt (default)":a,t.appendChild(n)}t.value=l||""}function k(e,t){const l=t!==void 0?t:e.value;e.textContent="";for(const n of A()){const i=document.createElement("option");i.value=n,i.textContent=n,e.appendChild(i)}const a=document.createElement("option");if(a.value="__custom__",a.textContent="custom dot-path\u2026",e.appendChild(a),l&&[...e.options].some(n=>n.value===l))e.value=l;else if(l){const n=document.createElement("option");n.value=l,n.textContent=l,e.insertBefore(n,a),e.value=l}}const $=o("ep-filter-rows");function L(e="",t="_eq",l=""){const a=document.createElement("div");a.className="ep-filter-row",a.style.cssText="display:grid;grid-template-columns:2fr 1.2fr 2fr auto;gap:.5rem;margin-bottom:.5rem;align-items:center;";const n=document.createElement("select");n.className="form-input ep-f-field",k(n,e||void 0),n.addEventListener("change",()=>{if(n.value==="__custom__"){const f=prompt("Dot-path field (e.g. address.city):","");f&&f.trim()?k(n,f.trim()):n.value=A()[0]||""}d()});const i=document.createElement("select");i.className="form-input ep-f-op";for(const[f,q]of D){const C=document.createElement("option");C.value=f,C.textContent=q,i.appendChild(C)}i.value=t,i.addEventListener("change",d);const s=document.createElement("input");s.type="text",s.className="form-input ep-f-value",s.placeholder="e.g. {{params.date}} or {{query.team}} or a literal",s.value=l,s.addEventListener("input",d);const c=document.createElement("button");c.className="btn btn-sm btn-ghost",c.innerHTML='<span data-icon="trash"></span>',c.addEventListener("click",()=>{a.remove(),d()}),a.appendChild(n),a.appendChild(i),a.appendChild(s),a.appendChild(c),$.appendChild(a),Domma.icons.scan(a)}function W(){const e={};for(const t of $.querySelectorAll(".ep-filter-row")){const l=t.querySelector(".ep-f-field").value,a=t.querySelector(".ep-f-op").value,n=t.querySelector(".ep-f-value").value;!l||l==="__custom__"||(e[a==="_eq"?l:l+a]=n)}return e}function w(){return{name:o("ep-name").value.trim(),project:v.value,path:o("ep-path").value.trim(),collection:p.value,auth:x.value,mode:o("ep-mode").value,filter:W(),sort:o("ep-sort").value||null,order:o("ep-order").value,limit:parseInt(o("ep-limit").value,10)||0,fields:[...h.querySelectorAll(".ep-field-cb:checked")].map(e=>e.value),enabled:o("ep-enabled").checked}}let _=null,m=null;function z(){return!_||JSON.stringify(w())!==_}function d(){B(),j()}function B(){o("ep-url-preview").textContent=`/api/x/${v.value||"<project>"}${o("ep-path").value||"/\u2026"}`}function Z(e){return(e.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g)||[]).map(t=>t.slice(1))}function j(){const e=z();o("tryit-dirty-note").style.display=e?"":"none",o("tryit-send").disabled=e,o("tryit-token-group").style.display=m?.auth==="token"?"":"none";const t=o("tryit-params"),l={};for(const a of t.querySelectorAll("input"))l[a.dataset.param]=a.value;t.textContent="";for(const a of Z(m?.path||"")){const n=document.createElement("div");n.className="form-group";const i=document.createElement("label");i.className="form-label",i.textContent=`:${a}`;const s=document.createElement("input");s.type="text",s.className="form-input",s.dataset.param=a,s.value=l[a]||"",n.appendChild(i),n.appendChild(s),t.appendChild(n)}}let I="";async function K(){if(!m)return;let e=m.path;for(const c of o("tryit-params").querySelectorAll("input"))e=e.replace(":"+c.dataset.param,encodeURIComponent(c.value));const t=o("tryit-query").value.trim(),l=`/api/x/${m.project}${e}${t?"?"+t:""}`,a={},n=o("tryit-token").value.trim();m.auth==="token"&&n&&(a.Authorization=`Bearer ${n}`);const i=location.origin+l;I=`curl ${a.Authorization?`-H 'Authorization: ${a.Authorization}' `:""}'${i}'`;const s=o("tryit-send");s.disabled=!0;try{const c=await fetch(l,{headers:a}),f=await c.text();let q=f;try{q=JSON.stringify(JSON.parse(f),null,2)}catch{}o("tryit-result").style.display="";const C=o("tryit-status");C.textContent=`${c.status} ${c.statusText}`,C.className="badge "+(c.ok?"badge-success":c.status>=500?"badge-danger":"badge-warning"),o("tryit-body").textContent=q}catch(c){o("tryit-result").style.display="",o("tryit-status").textContent="Network error",o("tryit-status").className="badge badge-danger",o("tryit-body").textContent=String(c.message||c)}finally{s.disabled=z()}}if(r){if(o("ep-name").value=r.name||"",v.value=r.project,v.disabled=!0,o("ep-path").value=r.path||"",N(r.collection),p.value!==r.collection){const e=document.createElement("option");e.value=r.collection,e.textContent=`${r.collection} (other project \u2014 no longer allowed)`,p.appendChild(e),p.value=r.collection}x.value=r.auth||"public",o("ep-mode").value=r.mode||"list",o("ep-order").value=r.order||"desc",o("ep-limit").value=r.limit??50,o("ep-enabled").checked=r.enabled!==!1,await S(),P(r.fields||[]),R(r.sort||"");for(const[e,t]of Object.entries(r.filter||{})){const{field:l,op:a}=G(e);L(l,a,String(t))}m=r,_=JSON.stringify(w())}else await S(),L();B(),j();for(const e of["ep-name","ep-path","ep-limit"])o(e).addEventListener("input",d);for(const e of["ep-mode","ep-order","ep-sort"])o(e).addEventListener("change",d);o("ep-enabled").addEventListener("change",d),v.addEventListener("change",async()=>{N(),await S(),d()}),x.addEventListener("change",d),p.addEventListener("change",async()=>{await S(),d()}),o("ep-add-filter").addEventListener("click",()=>{L(),d()}),o("tryit-send").addEventListener("click",K),o("tryit-copy-curl").addEventListener("click",async()=>{if(!I){E.toast("Send a request first.",{type:"info"});return}try{await navigator.clipboard.writeText(I),E.toast("curl command copied.",{type:"success"})}catch{E.toast("Copy failed.",{type:"error"})}}),o("ep-save").addEventListener("click",async()=>{const e=w();if(!e.name){E.toast("A name is required.",{type:"error"});return}if(!e.path.startsWith("/")){E.toast('The path must start with "/".',{type:"error"});return}try{if(g)m=await y.apiEndpoints.update(g,e),_=JSON.stringify(w()),E.toast("Endpoint saved.",{type:"success"}),j();else{const t=await y.apiEndpoints.create(e);E.toast("Endpoint created.",{type:"success"}),location.hash="#/api-endpoints/edit/"+encodeURIComponent(t.id)}}catch(t){E.toast(`Save failed: ${t.message||t}`,{type:"error"})}}),Domma.icons.scan(h)}};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
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",
@@ -23,7 +23,8 @@
23
23
  "scripts/",
24
24
  "docs/",
25
25
  "CHANGELOG.md",
26
- "CLAUDE.md"
26
+ "CLAUDE.md",
27
+ "FEATURES.md"
27
28
  ],
28
29
  "scripts": {
29
30
  "test": "node --test --test-concurrency=1 'tests/**/*.test.js'",
@@ -23,6 +23,24 @@ import {
23
23
  updateEndpoint
24
24
  } from '../../services/apiEndpoints.js';
25
25
  import {canSeeArtefact} from '../../services/projects.js';
26
+ import {getCollection} from '../../services/collections.js';
27
+
28
+ /**
29
+ * Scoped users must not expose data they cannot see: the collection an
30
+ * endpoint queries is itself an artefact subject to project access scope.
31
+ * Returns true when the user may use the collection (or it doesn't exist —
32
+ * the service's validation produces the clearer 400 for that case).
33
+ *
34
+ * @param {object} user
35
+ * @param {string} slug
36
+ * @returns {Promise<boolean>}
37
+ */
38
+ async function canUseCollection(user, slug) {
39
+ if (!slug) return true;
40
+ const schema = await getCollection(slug).catch(() => null);
41
+ if (!schema) return true;
42
+ return canSeeArtefact(user, schema);
43
+ }
26
44
 
27
45
  /**
28
46
  * Register the api-endpoints routes.
@@ -60,6 +78,9 @@ export async function apiEndpointsRoutes(fastify, opts = {}) {
60
78
  if (input.project && !canSeeArtefact(request.user, {meta: {project: input.project}})) {
61
79
  return reply.status(403).send({error: 'Access denied for this project'});
62
80
  }
81
+ if (!await canUseCollection(request.user, input.collection)) {
82
+ return reply.status(403).send({error: 'Access denied for this collection'});
83
+ }
63
84
  try {
64
85
  const creator = request.user?.name || request.user?.email || null;
65
86
  const def = await createEndpoint({...input, createdBy: creator});
@@ -77,6 +98,9 @@ export async function apiEndpointsRoutes(fastify, opts = {}) {
77
98
  }
78
99
  // The project binding is the endpoint's URL namespace — immutable.
79
100
  const {project: _ignored, ...patch} = request.body || {};
101
+ if (!await canUseCollection(request.user, patch.collection ?? existing.collection)) {
102
+ return reply.status(403).send({error: 'Access denied for this collection'});
103
+ }
80
104
  try {
81
105
  return await updateEndpoint(request.params.id, patch);
82
106
  } catch (err) {
@@ -14,7 +14,7 @@
14
14
  * no way for a definition to execute code.
15
15
  */
16
16
  import {createEntry, deleteEntry, getCollection, getEntry, listEntries, updateEntry} from './collections.js';
17
- import {canSeeArtefact, getProject} from './projects.js';
17
+ import {CORE_PROJECT_SLUG, canSeeArtefact, getProject, resolveArtefactProject} from './projects.js';
18
18
  import {getRoleMap} from './roles.js';
19
19
  import {parseFilterKey} from './filterEngine.js';
20
20
  import {hooks} from './hooks.js';
@@ -113,6 +113,13 @@ async function validateDefinition(data, {excludeId} = {}) {
113
113
  if (schema.systemManaged || schema.preset) {
114
114
  throw new Error(`Collection "${data.collection}" is system-managed and cannot be exposed`);
115
115
  }
116
+ // An endpoint may only expose collections from its own project or core —
117
+ // the URL namespace must match where the data lives, and scoped users
118
+ // must not be able to publish another project's data through their own.
119
+ const collectionProject = resolveArtefactProject(schema);
120
+ if (collectionProject !== data.project && collectionProject !== CORE_PROJECT_SLUG) {
121
+ throw new Error(`Collection "${data.collection}" belongs to project "${collectionProject}" — an endpoint may only expose collections from its own project or core`);
122
+ }
116
123
 
117
124
  if (typeof data.path !== 'string' || !data.path.startsWith('/')) {
118
125
  throw new Error('Path must start with "/"');
@@ -1177,12 +1177,26 @@ export function parseShortcodeAttrs(attrStr) {
1177
1177
  * @param {string} markdown
1178
1178
  * @returns {{ scrubbed: string, restore: (s: string) => string }}
1179
1179
  */
1180
+ /**
1181
+ * Monotonic id namespacing each scrubCodeRegions call. Scrub/restore pairs
1182
+ * NEST: a shortcode handler may run nested processors (processCardBlocks,
1183
+ * processGridBlocks, …) on an already-scrubbed body, and each of those scrubs
1184
+ * again. Without a per-call id, the inner restore's regex also matched the
1185
+ * OUTER call's placeholders and looked them up in the wrong store —
1186
+ * `store[idx]` → undefined, which surfaced as literal "undefined" strings
1187
+ * wherever inline code appeared inside [reveal]/effects bodies. With the id,
1188
+ * each restore only touches its own placeholders and the outer restore picks
1189
+ * up the rest on the way out.
1190
+ */
1191
+ let scrubCallId = 0;
1192
+
1180
1193
  export function scrubCodeRegions(markdown) {
1194
+ const callId = ++scrubCallId;
1181
1195
  const store = [];
1182
1196
  const placeholder = (s) => {
1183
1197
  const idx = store.length;
1184
1198
  store.push(s);
1185
- return `\x02SC${idx}\x03`;
1199
+ return `\x02SC${callId}:${idx}\x03`;
1186
1200
  };
1187
1201
 
1188
1202
  // HTML <pre><code>...</code></pre> blocks (rendered by earlier marked.parse calls inside accordion/carousel etc.)
@@ -1192,7 +1206,7 @@ export function scrubCodeRegions(markdown) {
1192
1206
  // Inline code spans (single or multiple backticks, non-greedy)
1193
1207
  scrubbed = scrubbed.replace(/`+[^`\n]+`+/g, placeholder);
1194
1208
 
1195
- const restore = (s) => s.replace(/\x02SC(\d+)\x03/g, (_, i) => store[parseInt(i, 10)]);
1209
+ const restore = (s) => s.replace(new RegExp(`\\x02SC${callId}:(\\d+)\\x03`, 'g'), (_, i) => store[parseInt(i, 10)]);
1196
1210
  return {scrubbed, restore};
1197
1211
  }
1198
1212
 
@@ -1306,7 +1320,9 @@ function processEffectsBlocks(markdown) {
1306
1320
  if (attrs.continuous === 'true') classes.push('firework-continuous');
1307
1321
  if (attrs.hover === 'true') classes.push('firework-on-hover');
1308
1322
  if (body === null) return `<div class="${classes.join(' ')}"></div>`;
1309
- const innerHtml = marked.parse(processCardBlocks(processGridBlocks(body.trim())));
1323
+ // Restore code placeholders before the nested parse (tabs-handler
1324
+ // pattern) so code spans/fences render as <code>, not raw backticks.
1325
+ const innerHtml = marked.parse(processCardBlocks(processGridBlocks(restore(body.trim()))));
1310
1326
  return `<div class="${classes.join(' ')}">${innerHtml}</div>\n`;
1311
1327
  });
1312
1328
 
@@ -1321,7 +1337,9 @@ function processEffectsBlocks(markdown) {
1321
1337
  const dataAttrs = Object.entries(attrs).map(([k, v]) => ` data-fx-${k}="${escapeAttr(v)}"`).join('');
1322
1338
  const hasActions = /\[\s*(render|wait|undo|redraw)\b/.test(body);
1323
1339
  if (!hasActions) {
1324
- const innerHtml = marked.parse(processCardBlocks(processGridBlocks(body.trim())));
1340
+ // Restore code placeholders before the nested parse (tabs-handler
1341
+ // pattern) so code spans/fences render as <code>, not raw backticks.
1342
+ const innerHtml = marked.parse(processCardBlocks(processGridBlocks(restore(body.trim()))));
1325
1343
  return `<div class="dm-fx-scribe"${dataAttrs}>${innerHtml}</div>\n`;
1326
1344
  }
1327
1345
  const actions = [];
@@ -1358,7 +1376,9 @@ function processEffectsBlocks(markdown) {
1358
1376
  if (body === null) return '';
1359
1377
  const attrs = parseShortcodeAttrs(attrStr);
1360
1378
  const dataAttrs = Object.entries(attrs).map(([k, v]) => ` data-fx-${k}="${v}"`).join('');
1361
- const innerHtml = marked.parse(processCardBlocks(processGridBlocks(body.trim())));
1379
+ // Restore code placeholders before the nested parse (tabs-handler
1380
+ // pattern) so code spans/fences render as <code>, not raw backticks.
1381
+ const innerHtml = marked.parse(processCardBlocks(processGridBlocks(restore(body.trim()))));
1362
1382
  return `<div class="dm-fx-${name}"${dataAttrs}>${innerHtml}</div>\n`;
1363
1383
  });
1364
1384
  }
@@ -1373,7 +1393,9 @@ function processEffectsBlocks(markdown) {
1373
1393
  if (body === null) {
1374
1394
  return `<div class="dm-fx-ticker-tape" data-fx-mode="page"${dataAttrs}></div>`;
1375
1395
  }
1376
- const innerHtml = marked.parse(processCardBlocks(processGridBlocks(body.trim())));
1396
+ // Restore code placeholders before the nested parse (tabs-handler
1397
+ // pattern) so code spans/fences render as <code>, not raw backticks.
1398
+ const innerHtml = marked.parse(processCardBlocks(processGridBlocks(restore(body.trim()))));
1377
1399
  return `<div class="dm-fx-ticker-tape" data-fx-mode="container"${dataAttrs}>${innerHtml}</div>\n`;
1378
1400
  });
1379
1401
 
@@ -1385,7 +1407,7 @@ function processEffectsBlocks(markdown) {
1385
1407
  if (attrs.duration) classes.push(`animate-duration-${attrs.duration}`);
1386
1408
  if (attrs.delay) classes.push(`animate-delay-${attrs.delay}`);
1387
1409
  if (attrs.repeat) classes.push(`animate-${attrs.repeat}`);
1388
- return `<div class="${classes.join(' ')}">${marked.parse(processCardBlocks(processGridBlocks(body.trim())))}</div>\n`;
1410
+ return `<div class="${classes.join(' ')}">${marked.parse(processCardBlocks(processGridBlocks(restore(body.trim()))))}</div>\n`;
1389
1411
  });
1390
1412
 
1391
1413
  apply('ambient', (attrStr, body) => {
@@ -1395,7 +1417,7 @@ function processEffectsBlocks(markdown) {
1395
1417
  if (attrs.type) classes.push(`bg-ambient-${attrs.type}`);
1396
1418
  if (attrs.speed) classes.push(`bg-ambient-${attrs.speed}`);
1397
1419
  if (attrs.intensity) classes.push(`bg-ambient-${attrs.intensity}`);
1398
- return `<div class="${classes.join(' ')}">${marked.parse(processCardBlocks(processGridBlocks(body.trim())))}</div>\n`;
1420
+ return `<div class="${classes.join(' ')}">${marked.parse(processCardBlocks(processGridBlocks(restore(body.trim()))))}</div>\n`;
1399
1421
  });
1400
1422
 
1401
1423
  return restore(result);
@@ -73,7 +73,8 @@ const SEED_ENTRIES = [
73
73
  permissions: [
74
74
  'pages', 'media', 'blocks', 'navigation', 'layouts',
75
75
  'collections', 'views', 'actions',
76
- 'users', 'settings', 'notifications', 'plugins'
76
+ 'users', 'settings', 'notifications', 'plugins',
77
+ 'api-tokens', 'api-endpoints'
77
78
  ],
78
79
  badgeClass: 'badge-warning'
79
80
  },
@@ -198,21 +199,30 @@ export async function seed() {
198
199
  await writeData(entries);
199
200
  }
200
201
 
201
- // Self-heal: ensure the level-0 root role always carries every registry
202
- // resource. Role permissions are a persisted snapshot taken at seed time,
203
- // so new permission families added in an update (e.g. api-tokens) would
204
- // otherwise be invisible even to the super-admin on existing installs.
205
- // Other roles are intentionally NOT back-filled; admins grant new
206
- // families via the role editor.
207
- const root = entries.find(e => e.data?.level === 0);
208
- if (root) {
209
- const perms = root.data.permissions || [];
210
- const missing = RESOURCES.filter(r => !perms.some(p => p === r || p.startsWith(`${r}.`)));
202
+ // Self-heal: base roles always gain permissions newly added to their SEED
203
+ // definitions in an update. Role permissions are a persisted snapshot
204
+ // taken at seed time, so a new family (e.g. api-endpoints) would
205
+ // otherwise be invisible on existing installs until granted by hand.
206
+ // Append-only permissions an admin ADDED are preserved; note that a
207
+ // seeded permission deliberately removed from a base role is re-added on
208
+ // boot (use a custom role to run with less than the base set). The root
209
+ // role's seed list is the full registry, so it always carries every
210
+ // resource. Custom (non-seed) roles are never touched.
211
+ let healed = false;
212
+ for (const seedRole of SEED_ENTRIES) {
213
+ const onDisk = seedRole.level === 0
214
+ ? entries.find(e => e.data?.level === 0) // root may be renamed
215
+ : entries.find(e => e.data?.name === seedRole.name);
216
+ if (!onDisk) continue;
217
+ const perms = onDisk.data.permissions || [];
218
+ const seedPerms = seedRole.level === 0 ? RESOURCES : seedRole.permissions;
219
+ const missing = seedPerms.filter(r => !perms.some(p => p === r || p.startsWith(`${r}.`)));
211
220
  if (missing.length) {
212
- root.data.permissions = [...perms, ...missing];
213
- await writeData(entries);
221
+ onDisk.data.permissions = [...perms, ...missing];
222
+ healed = true;
214
223
  }
215
224
  }
225
+ if (healed) await writeData(entries);
216
226
 
217
227
  // Migrate existing user files whose role is no longer recognised
218
228
  await migrateUserRoles(entries);