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}`:
|
|
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.*` —
|
|
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
|
|
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")}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.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/server/services/roles.js
CHANGED
|
@@ -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:
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
// otherwise be invisible
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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);
|