Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/power-pages/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ These patterns have caused repeated PR review feedback. Check for them before su
- **Phase cross-references break silently** — When renumbering or reordering phases in a SKILL.md, also update: `references/` docs that mention phase numbers, the Key Decision Points section, and any other files that cross-reference this skill's phases. After any phase reorder, grep for the old phase number across the skill directory and its references.
- **Validators must match the exact constraint** — If the rule is "no exports at all", block all `module.exports`/`exports` — don't just check if exported names are in an allowlist. If the rule is "try/catch required", verify both `try` AND `catch` exist. Re-read the exact constraint from SKILL.md and test the boundary cases.
- **Hook scripts run on every Skill tool use** — The PostToolUse hook fires for all tracked skills, so unconditional `process.stderr.write` creates noise. Gate debug logging behind `process.env.DEBUG`. Only errors should go to stderr unconditionally.
- **Template placeholders in `<script>` blocks need special care** — `render-template.js` injects string values as-is (no encoding), which is safe for HTML text contexts but risky inside JavaScript. Avoid declaring JS variables with `"__PLACEHOLDER__"` in script blocks; prefer reading from the DOM or using `JSON.stringify` for JS contexts.
- **Template placeholders are context-encoded** — bare string placeholders render as HTML text, while structured values render as script-safe JSON. Use `__JSON_KEY__` for every JavaScript or `application/json` value, `__ATTR_KEY__` for attributes, and reserve `__RAW_KEY__` for code-owned trusted markup.
- **Guidance must be consistent within a skill** — If one section says "always use raw fetch", a framework-specific table in the same file must not recommend a different HTTP client without qualification. Reviewers will flag contradictions.

## Telemetry
Expand Down
149 changes: 96 additions & 53 deletions plugins/power-pages/agents/assets/data-model-plan.html

Large diffs are not rendered by default.

83 changes: 56 additions & 27 deletions plugins/power-pages/agents/assets/permissions-plan.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-__ATTR_CSP_NONCE__'; style-src 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'"/>
<title>Table Permissions Plan — __SITE_NAME__</title>
<style>
:root {
Expand Down Expand Up @@ -216,7 +217,7 @@ <h3>Legend</h3>
</div>

<div style="display:flex;justify-content:flex-end;align-items:center;margin-bottom:16px;">
<button id="expandAllBtn" onclick="toggleExpandAll()" style="padding:5px 12px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--surface);color:var(--text-dim);font-size:12px;font-weight:600;cursor:pointer;font-family:var(--sans);transition:all 0.15s;">Expand All</button>
<button id="expandAllBtn" style="padding:5px 12px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--surface);color:var(--text-dim);font-size:12px;font-weight:600;cursor:pointer;font-family:var(--sans);transition:all 0.15s;">Expand All</button>
</div>
<div id="permsContainer"></div>
</div>
Expand All @@ -225,21 +226,41 @@ <h3>Legend</h3>
</div>
</div>

<script>
<script nonce="__ATTR_CSP_NONCE__">
// Data is populated by the table-permissions-architect agent
const SITE_NAME = "__SITE_NAME__";

const ROLES = __ROLES_DATA__;
const ROLES = __JSON_ROLES_DATA__;
// Each role: { id, name, desc, builtin, isNew, color }
// builtin: true only for "Authenticated Users" and "Anonymous Users"
// isNew: true if proposed by this plan, false if already exists in .powerpages-site/web-roles/

const PERMS = __PERMISSIONS_DATA__;
const PERMS = __JSON_PERMISSIONS_DATA__;
// Each perm: { id, name, displayName, table, scope, read, create, write, delete, append, appendto, roles (array of role ids), parent (perm id or null), parentRelationship, rationale: { scope, read, create, write, delete, append, appendto }, isNew }

const RATIONALE = __RATIONALE_DATA__;
const RATIONALE = __JSON_RATIONALE_DATA__;
// Array of { icon, title, desc }

function esc(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

function safeIcon(value) {
const icon = String(value ?? '');
return /^(?:&#(?:x[0-9a-f]+|\d+);)+$/i.test(icon) ? icon : esc(icon);
}
Comment thread
priyanshu92 marked this conversation as resolved.

function safeScope(value) {
return ['Global', 'Contact', 'Account', 'Parent', 'Self'].includes(value) ? value : 'Self';
}

function safeColor(value) {
return /^#[0-9a-f]{6}$/i.test(String(value ?? '')) ? value : '#8890a4';
}

// Tab navigation
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.addEventListener('click', () => {
Expand All @@ -251,7 +272,7 @@ <h3>Legend</h3>
});

function getRoleName(id) { return ROLES.find(r => r.id === id)?.name || id; }
function getRoleColor(id) { return ROLES.find(r => r.id === id)?.color || "#8890a4"; }
function getRoleColor(id) { return safeColor(ROLES.find(r => r.id === id)?.color); }

const scopeColors = {
Global: 'var(--critical)', Contact: 'var(--pass)', Account: 'var(--accent)',
Expand Down Expand Up @@ -292,10 +313,10 @@ <h3>Legend</h3>
let html = '';
RATIONALE.forEach(r => {
html += `<div class="principle">
<div class="principle-icon">${r.icon}</div>
<div class="principle-icon">${safeIcon(r.icon)}</div>
<div>
<div class="principle-title">${r.title}</div>
<div class="principle-desc">${r.desc}</div>
<div class="principle-title">${esc(r.title)}</div>
<div class="principle-desc">${esc(r.desc)}</div>
</div>
</div>`;
});
Expand All @@ -309,19 +330,23 @@ <h3>Legend</h3>
const sortedRoles = [...ROLES].sort((a, b) => (b.isNew ? 1 : 0) - (a.isNew ? 1 : 0));
sortedRoles.forEach(r => {
const perms = PERMS.filter(p => p.roles.includes(r.id));
const roleColor = safeColor(r.color);
html += `<div class="card" style="cursor:default;${r.isNew ? 'background:var(--accent-bg);border-color:var(--accent-border);' : ''}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;">
<div style="display:flex;align-items:center;gap:10px;">
<div style="width:10px;height:10px;border-radius:50%;background:${r.color};flex-shrink:0;"></div>
<div style="width:10px;height:10px;border-radius:50%;background:${roleColor};flex-shrink:0;"></div>
<div>
<span style="font-size:14px;font-weight:700;color:var(--text-bright)">${r.name}</span>
<span style="font-size:14px;font-weight:700;color:var(--text-bright)">${esc(r.name)}</span>
${r.builtin ? '<span class="builtin-badge">BUILT-IN</span>' : r.isNew ? '<span class="new-badge">PROPOSED</span>' : '<span class="existing-badge">EXISTING</span>'}
<div style="font-size:12px;color:var(--text-dim);margin-top:2px;">${r.desc}</div>
<div style="font-size:12px;color:var(--text-dim);margin-top:2px;">${esc(r.desc)}</div>
</div>
</div>
</div>
<div style="margin-top:10px;padding-top:10px;border-top:1px solid var(--border);display:flex;flex-wrap:wrap;gap:5px;">
${perms.length > 0 ? perms.map(p => `<span style="font-size:10px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:3px 8px;color:var(--text);"><span class="scope-tag scope-${p.scope}" style="font-size:9px;padding:1px 5px;min-width:auto;margin-right:4px;">${p.scope}</span>${p.name}</span>`).join('') : '<span style="font-size:11px;color:var(--text-dim);font-style:italic;">No direct table permissions</span>'}
${perms.length > 0 ? perms.map(p => {
const scope = safeScope(p.scope);
return `<span style="font-size:10px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:3px 8px;color:var(--text);"><span class="scope-tag scope-${scope}" style="font-size:9px;padding:1px 5px;min-width:auto;margin-right:4px;">${esc(scope)}</span>${esc(p.name)}</span>`;
}).join('') : '<span style="font-size:11px;color:var(--text-dim);font-style:italic;">No direct table permissions</span>'}
</div>
</div>`;
});
Expand All @@ -345,34 +370,34 @@ <h3>Legend</h3>

function renderPermCard(p, depth) {
const parentName = p.parent ? PERMS.find(x => x.id === p.parent)?.name : null;
const scope = safeScope(p.scope);
const privs = ['Read','Create','Write','Delete','Append','AppendTo'];
const privFlags = { Read: p.read, Create: p.create, Write: p.write, Delete: p.delete, Append: p.append, AppendTo: p.appendto };
const indent = depth * 32;
const children = childrenOf[p.id] || [];

let html = `<div style="position:relative;">`;

html += `<div class="card" style="border-left:3px solid ${scopeColors[p.scope]};padding:0;${p.isNew ? 'background:var(--accent-bg);border-color:var(--accent-border);' : ''}">
<div class="expandable-header" onclick="this.parentElement.classList.toggle('expanded')">
html += `<div class="card" style="border-left:3px solid ${scopeColors[scope]};padding:0;${p.isNew ? 'background:var(--accent-bg);border-color:var(--accent-border);' : ''}">
<div class="expandable-header">
${depth > 0 ? '<span style="font-size:15px;color:var(--purple);font-weight:700;margin-right:2px;">&#8627;</span>' : ''}
<span class="scope-tag scope-${p.scope}">${p.scope}</span>
<span style="font-size:13px;font-weight:600;color:var(--text-bright);flex:1;">${p.name}</span>
<span class="scope-tag scope-${scope}">${esc(scope)}</span>
<span style="font-size:13px;font-weight:600;color:var(--text-bright);flex:1;">${esc(p.name)}</span>
${p.isNew ? '<span class="new-badge">PROPOSED</span>' : '<span class="existing-badge">EXISTING</span>'}
<code style="font-size:11px;color:var(--accent);background:var(--accent-bg);padding:1px 6px;border-radius:3px;border:1px solid var(--accent-border);">${p.table}</code>
<code style="font-size:11px;color:var(--accent);background:var(--accent-bg);padding:1px 6px;border-radius:3px;border:1px solid var(--accent-border);">${esc(p.table)}</code>
<span style="display:flex;gap:3px;">${privs.map(pr => `<span class="priv ${privFlags[pr] ? 'priv-on' : 'priv-off'}">${pr[0]}${pr === 'AppendTo' ? 'T' : ''}</span>`).join('')}</span>
${children.length > 0 ? `<span style="font-size:10px;color:var(--purple);font-weight:700;font-family:var(--mono);background:var(--purple-bg);border:1px solid var(--purple-border);padding:1px 6px;border-radius:3px;">${children.length} child${children.length > 1 ? 'ren' : ''}</span>` : ''}
<span class="expand-chevron">&#9654;</span>
</div>
<div class="expandable-body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:12px;font-size:12px;">
<div><div class="field-label">Scope</div><span class="scope-tag scope-${p.scope}" style="font-size:10px;padding:1px 6px;min-width:auto;">${p.scope}${p.scope === 'Parent' && parentName ? ' \u2192 ' + parentName : ''}</span></div>
<div><div class="field-label">Display Name</div><span style="color:var(--text-bright);font-weight:600;">${p.displayName || p.name}</span></div>
<div><div class="field-label">Table Logical Name</div><span style="color:var(--text);font-family:var(--mono);font-size:11px;">${p.table}</span></div>
<div><div class="field-label">Roles</div>${p.roles.map(rid => `<span style="color:${getRoleColor(rid)};font-weight:600;margin-right:6px;">${getRoleName(rid)}</span>`).join('')}</div>
<div><div class="field-label">Scope</div><span class="scope-tag scope-${scope}" style="font-size:10px;padding:1px 6px;min-width:auto;">${esc(scope)}${scope === 'Parent' && parentName ? ' \u2192 ' + esc(parentName) : ''}</span></div>
<div><div class="field-label">Display Name</div><span style="color:var(--text-bright);font-weight:600;">${esc(p.displayName || p.name)}</span></div>
<div><div class="field-label">Table Logical Name</div><span style="color:var(--text);font-family:var(--mono);font-size:11px;">${esc(p.table)}</span></div>
<div><div class="field-label">Roles</div>${p.roles.map(rid => `<span style="color:${getRoleColor(rid)};font-weight:600;margin-right:6px;">${esc(getRoleName(rid))}</span>`).join('')}</div>
<div style="grid-column:span 2"><div class="field-label">Privileges</div>${privs.map(pr => `<span class="priv ${privFlags[pr] ? 'priv-on' : 'priv-off'}" style="margin-right:4px;">${pr}: ${privFlags[pr] ? '\u2713' : '\u2717'}</span>`).join('')}</div>
${p.parentRelationship ? `<div style="grid-column:span 2"><div class="field-label">Parent Relationship</div><span style="color:var(--text);font-family:var(--mono);font-size:11px;">${p.parentRelationship}</span> <span style="font-size:11px;color:var(--text-dim);">\u2192 ${parentName}</span></div>` : ''}
${p.parentRelationship ? `<div style="grid-column:span 2"><div class="field-label">Parent Relationship</div><span style="color:var(--text);font-family:var(--mono);font-size:11px;">${esc(p.parentRelationship)}</span> <span style="font-size:11px;color:var(--text-dim);">\u2192 ${esc(parentName)}</span></div>` : ''}
</div>
${p.rationale ? (() => { const labels = {read:'Read',create:'Create',write:'Write',delete:'Delete',append:'Append',appendto:'AppendTo'}; return `<div style="margin-top:12px;"><div class="field-label" style="margin-bottom:6px;">Reasoning</div><div style="font-size:12px;color:var(--text);background:var(--surface2);padding:10px 14px;border-radius:var(--radius-sm);border-left:2px solid var(--accent);line-height:1.8;"><ul class="reasoning-list">${p.rationale.scope ? `<li><strong>Scope:</strong> ${p.rationale.scope}</li>` : ''}${Object.entries(labels).map(([k,l]) => p.rationale[k] ? `<li><strong>${l}:</strong> ${p.rationale[k]}</li>` : '').join('')}</ul></div></div>`; })() : ''}
${p.rationale ? (() => { const labels = {read:'Read',create:'Create',write:'Write',delete:'Delete',append:'Append',appendto:'AppendTo'}; return `<div style="margin-top:12px;"><div class="field-label" style="margin-bottom:6px;">Reasoning</div><div style="font-size:12px;color:var(--text);background:var(--surface2);padding:10px 14px;border-radius:var(--radius-sm);border-left:2px solid var(--accent);line-height:1.8;"><ul class="reasoning-list">${p.rationale.scope ? `<li><strong>Scope:</strong> ${esc(p.rationale.scope)}</li>` : ''}${Object.entries(labels).map(([k,l]) => p.rationale[k] ? `<li><strong>${l}:</strong> ${esc(p.rationale[k])}</li>` : '').join('')}</ul></div></div>`; })() : ''}
</div>
</div>`;

Expand All @@ -393,6 +418,9 @@ <h3>Legend</h3>
roots.forEach(p => { html += renderPermCard(p, 0); });

c.innerHTML = html;
c.querySelectorAll('.expandable-header').forEach(header => {
header.addEventListener('click', () => header.parentElement.classList.toggle('expanded'));
});
}

// Expand All toggle
Expand All @@ -408,6 +436,7 @@ <h3>Legend</h3>
}

// Init
document.getElementById('expandAllBtn').addEventListener('click', toggleExpandAll);
renderRationale();
renderRoles();
renderPerms();
Expand Down
84 changes: 66 additions & 18 deletions plugins/power-pages/scripts/lib/render-template.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

const PLACEHOLDER_RE = /__(?:(HTML|ATTR|JSON|RAW)_)?([A-Z][A-Z0-9_]*)__/g;

/**
* @param {Object} options
Expand All @@ -17,9 +20,8 @@ const path = require('path');
* @param {string} [options.dataPath] - Absolute path to a JSON data file. Ignored if dataObject is provided.
* @param {Object} [options.dataObject] - Data object passed directly. If provided, takes precedence over dataPath.
* @param {string[]} options.requiredKeys - Keys that must be present in the data
* @param {boolean} [options.escapeStringValues=false] - Escape string values for HTML text contexts
*/
function renderTemplate({ templatePath, outputPath, dataPath, dataObject, requiredKeys, escapeStringValues = false }) {
function renderTemplate({ templatePath, outputPath, dataPath, dataObject, requiredKeys }) {
// Validate inputs exist
if (!fs.existsSync(templatePath)) {
console.error(`Template not found: ${templatePath}`);
Expand All @@ -45,22 +47,24 @@ function renderTemplate({ templatePath, outputPath, dataPath, dataObject, requir
process.exit(1);
}

// Replace all __KEY__ placeholders with corresponding values from the data object.
// For non-string values (arrays/objects serialized to JSON), escape `<` as `\u003c`
// so a literal `</script>` inside string data cannot close a containing <script> tag.
// Templates that place string placeholders in HTML text contexts can opt in to
// string escaping with escapeStringValues.
let result = template;
for (const [key, value] of Object.entries(data)) {
const placeholder = `__${key}__`;
const replacement = typeof value === 'string'
? (escapeStringValues ? escapeHtml(value) : value)
: JSON.stringify(value).replace(/</g, '\\u003c');
result = result.split(placeholder).join(replacement);
}
// Context is part of the placeholder because one source value can appear in both
// HTML and JavaScript. Bare string placeholders default to HTML text encoding;
// structured values default to JSON. RAW is reserved for code-owned markup.
const templateData = {
...data,
CSP_NONCE: crypto.randomBytes(16).toString('base64'),
};
const result = template.replace(PLACEHOLDER_RE, (placeholder, explicitContext, key) => {
if (!(key in templateData)) {
return placeholder;
}

const context = explicitContext || (typeof templateData[key] === 'string' ? 'HTML' : 'JSON');
return renderValue(templateData[key], context);
});

// Warn about any unreplaced placeholders (helps catch typos)
const remaining = result.match(/__[A-Z][A-Z0-9_]+__/g);
const remaining = result.match(PLACEHOLDER_RE);
if (remaining) {
const unique = [...new Set(remaining)];
console.error(`Warning: unreplaced placeholders: ${unique.join(', ')}`);
Expand Down Expand Up @@ -101,12 +105,50 @@ function renderTemplate({ templatePath, outputPath, dataPath, dataObject, requir
}

function escapeHtml(value) {
return value
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

function escapeHtmlAttribute(value) {
return escapeHtml(value)
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/`/g, '&#96;');
}

function serializeJson(value) {
const json = JSON.stringify(value);
if (json === undefined) {
throw new TypeError('Template values in JSON contexts must be JSON-serializable');
}

// HTML parses script end tags before JavaScript or application/json content.
// Neutralizing these characters keeps strings such as "</script>" inside JSON.
return json
.replace(/&/g, '\\u0026')
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}

function renderValue(value, context) {
switch (context) {
case 'HTML':
return escapeHtml(value);
case 'ATTR':
return escapeHtmlAttribute(value);
case 'JSON':
return serializeJson(value);
case 'RAW':
return String(value);
default:
throw new TypeError(`Unsupported template placeholder context: ${context}`);
}
}

function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i++) {
Expand All @@ -117,4 +159,10 @@ function parseArgs(argv) {
return args;
}

module.exports = { renderTemplate, parseArgs };
module.exports = {
renderTemplate,
parseArgs,
escapeHtml,
escapeHtmlAttribute,
serializeJson,
};
Loading
Loading