domma-cms 0.25.0 → 0.25.1

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.1",
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,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 "/"');
@@ -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);