bonescript-compiler 0.5.8 → 0.6.0

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.
Files changed (51) hide show
  1. package/dist/ast.d.ts +2 -0
  2. package/dist/cli.js +52 -8
  3. package/dist/cli.js.map +1 -1
  4. package/dist/emit_admin.d.ts +5 -0
  5. package/dist/emit_admin.js +340 -35
  6. package/dist/emit_admin.js.map +1 -1
  7. package/dist/emit_audit.js +38 -4
  8. package/dist/emit_audit.js.map +1 -1
  9. package/dist/emit_capability.js +14 -0
  10. package/dist/emit_capability.js.map +1 -1
  11. package/dist/emit_full.js +10 -2
  12. package/dist/emit_full.js.map +1 -1
  13. package/dist/emit_maintenance.js +35 -3
  14. package/dist/emit_maintenance.js.map +1 -1
  15. package/dist/emit_runtime.d.ts +18 -1
  16. package/dist/emit_runtime.js +212 -32
  17. package/dist/emit_runtime.js.map +1 -1
  18. package/dist/emit_websocket.js +22 -2
  19. package/dist/emit_websocket.js.map +1 -1
  20. package/dist/emit_zod.js +12 -1
  21. package/dist/emit_zod.js.map +1 -1
  22. package/dist/formatter.d.ts +1 -0
  23. package/dist/formatter.js +10 -2
  24. package/dist/formatter.js.map +1 -1
  25. package/dist/ir.d.ts +2 -0
  26. package/dist/lexer.d.ts +1 -0
  27. package/dist/lexer.js +4 -0
  28. package/dist/lexer.js.map +1 -1
  29. package/dist/lowering.js +2 -0
  30. package/dist/lowering.js.map +1 -1
  31. package/dist/parse_decls.js +36 -1
  32. package/dist/parse_decls.js.map +1 -1
  33. package/dist/typechecker.js +9 -0
  34. package/dist/typechecker.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/ast.ts +2 -0
  37. package/src/cli.ts +58 -10
  38. package/src/emit_admin.ts +342 -35
  39. package/src/emit_audit.ts +40 -4
  40. package/src/emit_capability.ts +13 -0
  41. package/src/emit_full.ts +9 -2
  42. package/src/emit_maintenance.ts +35 -3
  43. package/src/emit_runtime.ts +224 -32
  44. package/src/emit_websocket.ts +22 -2
  45. package/src/emit_zod.ts +11 -1
  46. package/src/formatter.ts +9 -2
  47. package/src/ir.ts +2 -0
  48. package/src/lexer.ts +2 -0
  49. package/src/lowering.ts +5 -3
  50. package/src/parse_decls.ts +31 -1
  51. package/src/typechecker.ts +10 -0
package/src/emit_admin.ts CHANGED
@@ -2,6 +2,11 @@
2
2
  * BoneScript Admin Panel Emitter
3
3
  * Generates a self-contained HTML admin UI from an IRSystem.
4
4
  * No build step — single HTML file with Tailwind CDN + vanilla JS.
5
+ *
6
+ * Security note: the rendering layer uses textContent / setAttribute exclusively
7
+ * for any value that might come from API responses. innerHTML is reserved for
8
+ * static markup composed within this file. This prevents stored-XSS payloads
9
+ * (e.g. a malicious record `name`) from executing when an admin views the panel.
5
10
  */
6
11
 
7
12
  import * as IR from "./ir";
@@ -23,6 +28,14 @@ function irTypeToDisplay(irType: string): string {
23
28
  return "text";
24
29
  }
25
30
 
31
+ /**
32
+ * HTML-escape a string for safe interpolation into static markup we control.
33
+ * Used for system.name in the title and similar trusted-but-defensive cases.
34
+ */
35
+ function escHtml(s: string): string {
36
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
37
+ }
38
+
26
39
  export function emitAdminPanel(system: IR.IRSystem): string {
27
40
  const apiModules = system.modules.filter(m => m.kind === "api_service" && m.models.length > 0);
28
41
 
@@ -60,40 +73,23 @@ export function emitAdminPanel(system: IR.IRSystem): string {
60
73
  });
61
74
 
62
75
  const configJson = JSON.stringify(entityConfigs, null, 2);
76
+ const titleEsc = escHtml(system.name);
63
77
 
64
- // Build nav buttons using string concatenation (no template literals in generated JS)
78
+ // Build static nav (entity names come from the IR, so they're trusted
79
+ // they were already validated by the parser/lexer).
65
80
  const navHtml = apiModules.map(mod => {
66
81
  const t = toSnakeCase(mod.models[0].name) + "s";
67
- const n = mod.models[0].name;
68
- return '<button onclick="loadEntity(\'' + t + '\')" id="nav-' + t + '" ' +
82
+ const n = escHtml(mod.models[0].name);
83
+ return '<button data-entity="' + t + '" id="nav-' + t + '" ' +
69
84
  'class="w-full text-left px-3 py-2 rounded text-sm text-gray-300 hover:bg-gray-700 hover:text-white transition-colors mb-1">' +
70
85
  n + '</button>';
71
86
  }).join("\n ");
72
87
 
73
- // The embedded JS uses only regular strings (no backticks) to avoid escaping issues
74
- const embeddedJs = [
75
- 'const BASE_URL = (document.querySelector(\'meta[name="bonescript-api-url"]\') || {}).content || "http://localhost:3000";',
76
- 'const ENTITIES = ' + configJson + ';',
77
- 'let currentEntity = null, currentPage = 1, editingId = null, pendingCapability = null;',
78
- 'let authToken = localStorage.getItem("admin_token") || "";',
79
- 'if (authToken) { document.getElementById("token-input").value = authToken; document.getElementById("auth-status").textContent = "Token set"; }',
80
- 'function setToken(v) { authToken = v; localStorage.setItem("admin_token", v); document.getElementById("auth-status").textContent = v ? "Token set" : "Not authenticated"; }',
81
- 'function headers() { const h = { "Content-Type": "application/json" }; if (authToken) h["Authorization"] = "Bearer " + authToken; return h; }',
82
- 'function toast(msg, type) { const el = document.createElement("div"); el.className = "toast px-4 py-2 rounded shadow text-sm text-white " + (type === "error" ? "bg-red-500" : "bg-green-500"); el.textContent = msg; document.getElementById("toast-container").appendChild(el); setTimeout(function() { el.remove(); }, 3500); }',
83
- 'function loadEntity(t) { currentEntity = ENTITIES.find(function(e) { return e.tableName === t; }); if (!currentEntity) return; currentPage = 1; document.querySelectorAll("[id^=nav-]").forEach(function(el) { el.classList.remove("bg-gray-700", "text-white"); el.classList.add("text-gray-300"); }); var n = document.getElementById("nav-" + t); if (n) { n.classList.add("bg-gray-700", "text-white"); n.classList.remove("text-gray-300"); } document.getElementById("page-title").textContent = currentEntity.name; document.getElementById("page-subtitle").textContent = currentEntity.apiPath; document.getElementById("create-btn").classList.remove("hidden"); document.getElementById("refresh-btn").classList.remove("hidden"); refreshTable(); }',
84
- 'async function refreshTable() { if (!currentEntity) return; var area = document.getElementById("content-area"); area.classList.add("loading"); try { var res = await fetch(BASE_URL + currentEntity.apiPath + "?page=" + currentPage + "&page_size=50", { headers: headers() }); var data = await res.json(); if (!res.ok) { area.innerHTML = "<p class=\\"text-red-600\\">" + (data.error && data.error.message || "Error") + "</p>"; return; } var items = data.items || []; var total = data.total || 0; var cols = currentEntity.columns; var caps = currentEntity.capabilities; var html = "<div class=\\"bg-white rounded-lg shadow overflow-hidden\\"><div class=\\"px-4 py-3 border-b flex items-center justify-between\\"><span class=\\"text-sm text-gray-500\\">" + total + " total</span>"; if (caps.length > 0) { html += "<div class=\\"flex gap-2\\">"; for (var i = 0; i < caps.length; i++) { html += "<button onclick=\\"runCapability(\'" + caps[i].endpoint + "\',\'" + caps[i].label + "\')\\" class=\\"text-xs bg-orange-100 text-orange-700 px-2 py-1 rounded\\">" + caps[i].label + "</button>"; } html += "</div>"; } html += "</div><div class=\\"overflow-x-auto\\"><table class=\\"w-full text-sm\\"><thead class=\\"bg-gray-50 border-b\\"><tr>"; for (var j = 0; j < cols.length; j++) { html += "<th class=\\"text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase\\">" + cols[j].label + "</th>"; } html += "<th class=\\"text-right px-4 py-3 text-xs\\">Actions</th></tr></thead><tbody class=\\"divide-y divide-gray-100\\">"; if (items.length === 0) { html += "<tr><td colspan=\\"" + (cols.length + 1) + "\\" class=\\"px-4 py-8 text-center text-gray-400\\">No records</td></tr>"; } for (var k = 0; k < items.length; k++) { var item = items[k]; html += "<tr class=\\"hover:bg-gray-50\\">"; for (var l = 0; l < cols.length; l++) { var col = cols[l]; var val = item[col.key]; var d = ""; if (val === null || val === undefined) { d = "<span class=\\"text-gray-300 italic\\">null</span>"; } else if (col.type === "boolean") { d = val ? "<span class=\\"text-green-600\\">Yes</span>" : "<span class=\\"text-red-400\\">No</span>"; } else if (col.type === "datetime") { d = "<span class=\\"text-gray-600\\">" + new Date(val).toLocaleString() + "</span>"; } else if (col.type === "uuid") { d = "<span class=\\"font-mono text-xs text-gray-500\\">" + String(val).slice(0, 8) + "...</span>"; } else if (col.type === "json" || col.type === "array") { d = "<span class=\\"font-mono text-xs text-gray-500\\">" + JSON.stringify(val).slice(0, 40) + "</span>"; } else { d = "<span class=\\"text-gray-800\\">" + String(val).slice(0, 60) + "</span>"; } html += "<td class=\\"px-4 py-3\\">" + d + "</td>"; } html += "<td class=\\"px-4 py-3 text-right\\"><button onclick=\\"openEditModal(\'" + item.id + "\')\\" class=\\"text-blue-600 hover:text-blue-800 text-xs mr-2\\">Edit</button><button onclick=\\"deleteRecord(\'" + item.id + "\')\\" class=\\"text-red-500 hover:text-red-700 text-xs\\">Delete</button></td></tr>"; } html += "</tbody></table></div></div>"; area.innerHTML = html; } catch(e) { area.innerHTML = "<p class=\\"text-red-600\\">Error: " + e.message + "</p>"; } finally { area.classList.remove("loading"); } }',
85
- 'function openCreateModal() { if (!currentEntity) return; editingId = null; document.getElementById("modal-title").textContent = "Create " + currentEntity.name; renderFormFields(null); document.getElementById("modal").classList.remove("hidden"); }',
86
- 'async function openEditModal(id) { if (!currentEntity) return; editingId = id; document.getElementById("modal-title").textContent = "Edit " + currentEntity.name; try { var res = await fetch(BASE_URL + currentEntity.apiPath + "/" + id, { headers: headers() }); var data = await res.json(); renderFormFields(data); document.getElementById("modal").classList.remove("hidden"); } catch(e) { toast("Failed to load: " + e.message, "error"); } }',
87
- 'function renderFormFields(data) { var fields = currentEntity.formFields; var html = ""; for (var i = 0; i < fields.length; i++) { var f = fields[i]; var val = data ? (data[f.key] !== undefined ? data[f.key] : "") : ""; var it = f.type === "boolean" ? "checkbox" : f.type === "datetime" ? "datetime-local" : f.type === "json" || f.type === "array" ? "textarea" : "text"; html += "<div><label class=\\"block text-sm font-medium text-gray-700 mb-1\\">" + f.label + (f.nullable ? "" : " *") + "</label>"; if (it === "checkbox") { html += "<input type=\\"checkbox\\" name=\\"" + f.key + "\\" " + (val ? "checked" : "") + " class=\\"h-4 w-4 text-blue-600 rounded border-gray-300\\" />"; } else if (it === "textarea") { html += "<textarea name=\\"" + f.key + "\\" rows=\\"3\\" class=\\"w-full border border-gray-300 rounded px-3 py-2 text-sm\\">" + (typeof val === "object" ? JSON.stringify(val, null, 2) : val) + "</textarea>"; } else { html += "<input type=\\"" + it + "\\" name=\\"" + f.key + "\\" value=\\"" + val + "\\" class=\\"w-full border border-gray-300 rounded px-3 py-2 text-sm\\" />"; } html += "</div>"; } document.getElementById("form-fields").innerHTML = html; }',
88
- 'async function submitForm(e) { e.preventDefault(); if (!currentEntity) return; var form = document.getElementById("modal-form"); var body = {}; for (var i = 0; i < currentEntity.formFields.length; i++) { var f = currentEntity.formFields[i]; var el = form.elements[f.key]; if (!el) continue; if (f.type === "boolean") { body[f.key] = el.checked; } else if (f.type === "json" || f.type === "array") { try { body[f.key] = JSON.parse(el.value); } catch(ex) { body[f.key] = el.value; } } else { body[f.key] = el.value; } } try { var url = editingId ? BASE_URL + currentEntity.apiPath + "/" + editingId : BASE_URL + currentEntity.apiPath; var method = editingId ? "PUT" : "POST"; var res = await fetch(url, { method: method, headers: headers(), body: JSON.stringify(body) }); var data = await res.json(); if (!res.ok) throw new Error(data.error && data.error.message || "Request failed"); toast(editingId ? "Updated" : "Created"); closeModal(); refreshTable(); } catch(e) { toast(e.message, "error"); } }',
89
- 'async function deleteRecord(id) { if (!confirm("Delete this record?")) return; try { var res = await fetch(BASE_URL + currentEntity.apiPath + "/" + id, { method: "DELETE", headers: headers() }); if (!res.ok) { var d = await res.json().catch(function() { return {}; }); throw new Error(d.error && d.error.message || "Delete failed"); } toast("Deleted"); refreshTable(); } catch(e) { toast(e.message, "error"); } }',
90
- 'function runCapability(endpoint, label) { pendingCapability = { endpoint: endpoint, label: label }; document.getElementById("cap-modal-title").textContent = label; document.getElementById("cap-modal-desc").textContent = "Run this capability?"; document.getElementById("cap-modal").classList.remove("hidden"); }',
91
- 'async function confirmCapability() { if (!pendingCapability) return; closeCapModal(); try { var res = await fetch(BASE_URL + pendingCapability.endpoint, { method: "POST", headers: headers(), body: JSON.stringify({}) }); var data = await res.json().catch(function() { return {}; }); if (!res.ok) throw new Error(data.error && data.error.message || "Failed"); toast(pendingCapability.label + " completed"); refreshTable(); } catch(e) { toast(e.message, "error"); } }',
92
- 'function closeModal() { document.getElementById("modal").classList.add("hidden"); editingId = null; }',
93
- 'function closeCapModal() { document.getElementById("cap-modal").classList.add("hidden"); pendingCapability = null; }',
94
- 'document.getElementById("modal").addEventListener("click", function(e) { if (e.target === e.currentTarget) closeModal(); });',
95
- 'document.getElementById("cap-modal").addEventListener("click", function(e) { if (e.target === e.currentTarget) closeCapModal(); });',
96
- ].join("\n");
88
+ // Embedded runtime JS. This is the security boundary for the admin panel.
89
+ // - All API-derived values flow through el.textContent or setAttribute.
90
+ // - innerHTML is only used for static structure (icons, fixed copy).
91
+ // - Event handlers are bound via addEventListener, never inline onclick=.
92
+ const embeddedJs = ADMIN_RUNTIME_JS.replace("__ENTITIES_JSON__", configJson);
97
93
 
98
94
  return [
99
95
  '<!DOCTYPE html>',
@@ -102,31 +98,342 @@ export function emitAdminPanel(system: IR.IRSystem): string {
102
98
  '<meta charset="UTF-8" />',
103
99
  '<meta name="viewport" content="width=device-width, initial-scale=1.0" />',
104
100
  '<meta name="bonescript-api-url" content="http://localhost:3000" />',
105
- '<title>' + system.name + ' Admin</title>',
101
+ '<title>' + titleEsc + ' Admin</title>',
106
102
  '<script src="https://cdn.tailwindcss.com"><\/script>',
107
- '<style>.loading{opacity:.5;pointer-events:none}.toast{animation:fadeout 3s forwards}@keyframes fadeout{0%,70%{opacity:1}100%{opacity:0}}<\/style>',
103
+ '<style>.loading{opacity:.5;pointer-events:none}.toast{animation:fadeout 3s forwards}@keyframes fadeout{0%,70%{opacity:1}100%{opacity:0}}</style>',
108
104
  '</head>',
109
105
  '<body class="bg-gray-50 min-h-screen">',
110
106
  '<div class="flex h-screen overflow-hidden">',
111
107
  '<aside class="w-64 bg-gray-900 text-white flex flex-col">',
112
- '<div class="p-4 border-b border-gray-700"><h1 class="text-lg font-bold">' + system.name + '</h1><p class="text-xs text-gray-400 mt-1">Admin Panel</p></div>',
108
+ '<div class="p-4 border-b border-gray-700"><h1 class="text-lg font-bold">' + titleEsc + '</h1><p class="text-xs text-gray-400 mt-1">Admin Panel</p></div>',
113
109
  '<nav class="flex-1 overflow-y-auto p-2" id="nav">' + navHtml + '</nav>',
114
110
  '<div class="p-4 border-t border-gray-700"><div class="text-xs text-gray-500"><div id="auth-status">Not authenticated</div>',
115
- '<input id="token-input" type="password" placeholder="Bearer token" class="mt-2 w-full bg-gray-800 text-white text-xs px-2 py-1 rounded border border-gray-600" onchange="setToken(this.value)" /></div></div>',
111
+ '<input id="token-input" type="password" placeholder="Bearer token" class="mt-2 w-full bg-gray-800 text-white text-xs px-2 py-1 rounded border border-gray-600" /></div></div>',
116
112
  '</aside>',
117
113
  '<main class="flex-1 overflow-y-auto">',
118
114
  '<div class="bg-white border-b px-6 py-4 flex items-center justify-between sticky top-0 z-10">',
119
115
  '<div><h2 class="text-xl font-semibold text-gray-800" id="page-title">Select an entity</h2><p class="text-sm text-gray-500" id="page-subtitle"></p></div>',
120
116
  '<div class="flex gap-2">',
121
- '<button onclick="openCreateModal()" id="create-btn" class="hidden bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700">+ Create</button>',
122
- '<button onclick="refreshTable()" id="refresh-btn" class="hidden bg-gray-100 text-gray-700 px-3 py-2 rounded text-sm hover:bg-gray-200">&#8635; Refresh</button>',
117
+ '<button id="create-btn" class="hidden bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700">+ Create</button>',
118
+ '<button id="refresh-btn" class="hidden bg-gray-100 text-gray-700 px-3 py-2 rounded text-sm hover:bg-gray-200">&#8635; Refresh</button>',
123
119
  '</div></div>',
124
120
  '<div id="toast-container" class="fixed top-4 right-4 z-50 flex flex-col gap-2"></div>',
125
121
  '<div class="p-6" id="content-area"><div class="text-center text-gray-400 mt-20"><div class="text-5xl mb-4">&#9889;</div><p class="text-lg">Select an entity from the sidebar</p><p class="text-sm mt-2">Generated by BoneScript compiler</p></div></div>',
126
122
  '</main></div>',
127
- '<div id="modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-40 flex items-center justify-center p-4"><div class="bg-white rounded-lg shadow-xl w-full max-w-lg max-h-screen overflow-y-auto"><div class="p-6"><div class="flex items-center justify-between mb-4"><h3 class="text-lg font-semibold" id="modal-title">Create</h3><button onclick="closeModal()" class="text-gray-400 hover:text-gray-600 text-2xl">&times;</button></div><form id="modal-form" onsubmit="submitForm(event)" class="space-y-4"><div id="form-fields"></div><div class="flex gap-2 pt-2"><button type="submit" class="flex-1 bg-blue-600 text-white py-2 rounded hover:bg-blue-700">Save</button><button type="button" onclick="closeModal()" class="flex-1 bg-gray-100 text-gray-700 py-2 rounded hover:bg-gray-200">Cancel</button></div></form></div></div></div>',
128
- '<div id="cap-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-40 flex items-center justify-center p-4"><div class="bg-white rounded-lg shadow-xl w-full max-w-md"><div class="p-6"><div class="flex items-center justify-between mb-4"><h3 class="text-lg font-semibold" id="cap-modal-title">Run Capability</h3><button onclick="closeCapModal()" class="text-gray-400 hover:text-gray-600 text-2xl">&times;</button></div><p class="text-sm text-gray-500 mb-4" id="cap-modal-desc"></p><div class="flex gap-2"><button onclick="confirmCapability()" class="flex-1 bg-orange-500 text-white py-2 rounded hover:bg-orange-600">Run</button><button onclick="closeCapModal()" class="flex-1 bg-gray-100 text-gray-700 py-2 rounded hover:bg-gray-200">Cancel</button></div></div></div></div>',
123
+ '<div id="modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-40 flex items-center justify-center p-4"><div class="bg-white rounded-lg shadow-xl w-full max-w-lg max-h-screen overflow-y-auto"><div class="p-6"><div class="flex items-center justify-between mb-4"><h3 class="text-lg font-semibold" id="modal-title">Create</h3><button id="modal-close" class="text-gray-400 hover:text-gray-600 text-2xl">&times;</button></div><form id="modal-form" class="space-y-4"><div id="form-fields"></div><div class="flex gap-2 pt-2"><button type="submit" class="flex-1 bg-blue-600 text-white py-2 rounded hover:bg-blue-700">Save</button><button type="button" id="modal-cancel" class="flex-1 bg-gray-100 text-gray-700 py-2 rounded hover:bg-gray-200">Cancel</button></div></form></div></div></div>',
124
+ '<div id="cap-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-40 flex items-center justify-center p-4"><div class="bg-white rounded-lg shadow-xl w-full max-w-md"><div class="p-6"><div class="flex items-center justify-between mb-4"><h3 class="text-lg font-semibold" id="cap-modal-title">Run Capability</h3><button id="cap-modal-close" class="text-gray-400 hover:text-gray-600 text-2xl">&times;</button></div><p class="text-sm text-gray-500 mb-4" id="cap-modal-desc"></p><div class="flex gap-2"><button id="cap-modal-confirm" class="flex-1 bg-orange-500 text-white py-2 rounded hover:bg-orange-600">Run</button><button id="cap-modal-cancel" class="flex-1 bg-gray-100 text-gray-700 py-2 rounded hover:bg-gray-200">Cancel</button></div></div></div></div>',
129
125
  '<script>' + embeddedJs + '<\/script>',
130
126
  '</body></html>',
131
127
  ].join("\n");
132
128
  }
129
+
130
+ // ─── Embedded admin runtime ───────────────────────────────────────────────────
131
+ // This is plain JS that gets injected into the generated HTML. It is the
132
+ // exclusive renderer for entity rows, forms, and toasts. Every value that
133
+ // could come from the API uses textContent / setAttribute. innerHTML is only
134
+ // used to swap large structural blocks where the contents are also assembled
135
+ // via DOM nodes (never via string concatenation of API data).
136
+ const ADMIN_RUNTIME_JS = `
137
+ (function() {
138
+ "use strict";
139
+ var BASE_URL = (document.querySelector('meta[name="bonescript-api-url"]') || {}).content || "http://localhost:3000";
140
+ var ENTITIES = __ENTITIES_JSON__;
141
+ var currentEntity = null, currentPage = 1, editingId = null, pendingCapability = null;
142
+ var authToken = localStorage.getItem("admin_token") || "";
143
+
144
+ function $(id) { return document.getElementById(id); }
145
+ function clear(el) { while (el.firstChild) el.removeChild(el.firstChild); }
146
+ function el(tag, attrs, text) {
147
+ var n = document.createElement(tag);
148
+ if (attrs) {
149
+ for (var k in attrs) {
150
+ if (k === "class") n.className = attrs[k];
151
+ else if (k === "dataset") {
152
+ for (var d in attrs[k]) n.dataset[d] = attrs[k][d];
153
+ } else n.setAttribute(k, attrs[k]);
154
+ }
155
+ }
156
+ if (text != null) n.textContent = String(text);
157
+ return n;
158
+ }
159
+
160
+ if (authToken) {
161
+ $("token-input").value = authToken;
162
+ $("auth-status").textContent = "Token set";
163
+ }
164
+ function setToken(v) {
165
+ authToken = v;
166
+ localStorage.setItem("admin_token", v);
167
+ $("auth-status").textContent = v ? "Token set" : "Not authenticated";
168
+ }
169
+ function headers() {
170
+ var h = { "Content-Type": "application/json" };
171
+ if (authToken) h["Authorization"] = "Bearer " + authToken;
172
+ return h;
173
+ }
174
+ function toast(msg, type) {
175
+ var t = el("div", { class: "toast px-4 py-2 rounded shadow text-sm text-white " + (type === "error" ? "bg-red-500" : "bg-green-500") }, msg);
176
+ $("toast-container").appendChild(t);
177
+ setTimeout(function() { t.remove(); }, 3500);
178
+ }
179
+ function showError(area, msg) {
180
+ clear(area);
181
+ area.appendChild(el("p", { class: "text-red-600" }, msg));
182
+ }
183
+
184
+ function loadEntity(t) {
185
+ currentEntity = ENTITIES.find(function(e) { return e.tableName === t; });
186
+ if (!currentEntity) return;
187
+ currentPage = 1;
188
+ var navs = document.querySelectorAll("[id^=nav-]");
189
+ for (var i = 0; i < navs.length; i++) {
190
+ navs[i].classList.remove("bg-gray-700", "text-white");
191
+ navs[i].classList.add("text-gray-300");
192
+ }
193
+ var n = $("nav-" + t);
194
+ if (n) {
195
+ n.classList.add("bg-gray-700", "text-white");
196
+ n.classList.remove("text-gray-300");
197
+ }
198
+ $("page-title").textContent = currentEntity.name;
199
+ $("page-subtitle").textContent = currentEntity.apiPath;
200
+ $("create-btn").classList.remove("hidden");
201
+ $("refresh-btn").classList.remove("hidden");
202
+ refreshTable();
203
+ }
204
+
205
+ function formatCell(val, type) {
206
+ if (val === null || val === undefined) {
207
+ return el("span", { class: "text-gray-300 italic" }, "null");
208
+ }
209
+ if (type === "boolean") {
210
+ return el("span", { class: val ? "text-green-600" : "text-red-400" }, val ? "Yes" : "No");
211
+ }
212
+ if (type === "datetime") {
213
+ return el("span", { class: "text-gray-600" }, new Date(val).toLocaleString());
214
+ }
215
+ if (type === "uuid") {
216
+ return el("span", { class: "font-mono text-xs text-gray-500" }, String(val).slice(0, 8) + "...");
217
+ }
218
+ if (type === "json" || type === "array") {
219
+ return el("span", { class: "font-mono text-xs text-gray-500" }, JSON.stringify(val).slice(0, 40));
220
+ }
221
+ return el("span", { class: "text-gray-800" }, String(val).slice(0, 60));
222
+ }
223
+
224
+ function buildCapabilityBar(caps) {
225
+ var bar = el("div", { class: "flex gap-2" });
226
+ for (var i = 0; i < caps.length; i++) {
227
+ (function(cap) {
228
+ var btn = el("button", { class: "text-xs bg-orange-100 text-orange-700 px-2 py-1 rounded" }, cap.label);
229
+ btn.addEventListener("click", function() { runCapability(cap.endpoint, cap.label); });
230
+ bar.appendChild(btn);
231
+ })(caps[i]);
232
+ }
233
+ return bar;
234
+ }
235
+
236
+ function buildRow(item, cols) {
237
+ var tr = el("tr", { class: "hover:bg-gray-50" });
238
+ for (var i = 0; i < cols.length; i++) {
239
+ var td = el("td", { class: "px-4 py-3" });
240
+ td.appendChild(formatCell(item[cols[i].key], cols[i].type));
241
+ tr.appendChild(td);
242
+ }
243
+ var actions = el("td", { class: "px-4 py-3 text-right" });
244
+ var editBtn = el("button", { class: "text-blue-600 hover:text-blue-800 text-xs mr-2" }, "Edit");
245
+ editBtn.addEventListener("click", function() { openEditModal(item.id); });
246
+ var delBtn = el("button", { class: "text-red-500 hover:text-red-700 text-xs" }, "Delete");
247
+ delBtn.addEventListener("click", function() { deleteRecord(item.id); });
248
+ actions.appendChild(editBtn);
249
+ actions.appendChild(delBtn);
250
+ tr.appendChild(actions);
251
+ return tr;
252
+ }
253
+
254
+ function buildTable(items, cols, caps, total) {
255
+ var card = el("div", { class: "bg-white rounded-lg shadow overflow-hidden" });
256
+ var head = el("div", { class: "px-4 py-3 border-b flex items-center justify-between" });
257
+ head.appendChild(el("span", { class: "text-sm text-gray-500" }, total + " total"));
258
+ if (caps.length > 0) head.appendChild(buildCapabilityBar(caps));
259
+ card.appendChild(head);
260
+
261
+ var wrap = el("div", { class: "overflow-x-auto" });
262
+ var table = el("table", { class: "w-full text-sm" });
263
+ var thead = el("thead", { class: "bg-gray-50 border-b" });
264
+ var headerRow = el("tr");
265
+ for (var j = 0; j < cols.length; j++) {
266
+ headerRow.appendChild(el("th", { class: "text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase" }, cols[j].label));
267
+ }
268
+ headerRow.appendChild(el("th", { class: "text-right px-4 py-3 text-xs" }, "Actions"));
269
+ thead.appendChild(headerRow);
270
+ table.appendChild(thead);
271
+
272
+ var tbody = el("tbody", { class: "divide-y divide-gray-100" });
273
+ if (items.length === 0) {
274
+ var emptyRow = el("tr");
275
+ var emptyCell = el("td", { class: "px-4 py-8 text-center text-gray-400" }, "No records");
276
+ emptyCell.setAttribute("colspan", String(cols.length + 1));
277
+ emptyRow.appendChild(emptyCell);
278
+ tbody.appendChild(emptyRow);
279
+ } else {
280
+ for (var k = 0; k < items.length; k++) tbody.appendChild(buildRow(items[k], cols));
281
+ }
282
+ table.appendChild(tbody);
283
+ wrap.appendChild(table);
284
+ card.appendChild(wrap);
285
+ return card;
286
+ }
287
+
288
+ async function refreshTable() {
289
+ if (!currentEntity) return;
290
+ var area = $("content-area");
291
+ area.classList.add("loading");
292
+ try {
293
+ var res = await fetch(BASE_URL + currentEntity.apiPath + "?page=" + currentPage + "&page_size=50", { headers: headers() });
294
+ var data = await res.json();
295
+ if (!res.ok) {
296
+ showError(area, (data.error && data.error.message) || "Error");
297
+ return;
298
+ }
299
+ clear(area);
300
+ area.appendChild(buildTable(data.items || [], currentEntity.columns, currentEntity.capabilities, data.total || 0));
301
+ } catch (e) {
302
+ showError(area, "Error: " + e.message);
303
+ } finally {
304
+ area.classList.remove("loading");
305
+ }
306
+ }
307
+
308
+ function openCreateModal() {
309
+ if (!currentEntity) return;
310
+ editingId = null;
311
+ $("modal-title").textContent = "Create " + currentEntity.name;
312
+ renderFormFields(null);
313
+ $("modal").classList.remove("hidden");
314
+ }
315
+ async function openEditModal(id) {
316
+ if (!currentEntity) return;
317
+ editingId = id;
318
+ $("modal-title").textContent = "Edit " + currentEntity.name;
319
+ try {
320
+ var res = await fetch(BASE_URL + currentEntity.apiPath + "/" + encodeURIComponent(id), { headers: headers() });
321
+ var data = await res.json();
322
+ renderFormFields(data);
323
+ $("modal").classList.remove("hidden");
324
+ } catch (e) {
325
+ toast("Failed to load: " + e.message, "error");
326
+ }
327
+ }
328
+ function renderFormFields(data) {
329
+ var fields = currentEntity.formFields;
330
+ var container = $("form-fields");
331
+ clear(container);
332
+ for (var i = 0; i < fields.length; i++) {
333
+ var f = fields[i];
334
+ var val = data ? (data[f.key] !== undefined ? data[f.key] : "") : "";
335
+ var wrap = el("div");
336
+ wrap.appendChild(el("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, f.label + (f.nullable ? "" : " *")));
337
+ var input;
338
+ if (f.type === "boolean") {
339
+ input = el("input", { type: "checkbox", name: f.key, class: "h-4 w-4 text-blue-600 rounded border-gray-300" });
340
+ input.checked = !!val;
341
+ } else if (f.type === "json" || f.type === "array") {
342
+ input = el("textarea", { name: f.key, rows: "3", class: "w-full border border-gray-300 rounded px-3 py-2 text-sm" });
343
+ input.value = typeof val === "object" ? JSON.stringify(val, null, 2) : (val == null ? "" : String(val));
344
+ } else {
345
+ var t = f.type === "datetime" ? "datetime-local" : "text";
346
+ input = el("input", { type: t, name: f.key, class: "w-full border border-gray-300 rounded px-3 py-2 text-sm" });
347
+ input.value = val == null ? "" : String(val);
348
+ }
349
+ wrap.appendChild(input);
350
+ container.appendChild(wrap);
351
+ }
352
+ }
353
+ async function submitForm(e) {
354
+ e.preventDefault();
355
+ if (!currentEntity) return;
356
+ var form = $("modal-form");
357
+ var body = {};
358
+ for (var i = 0; i < currentEntity.formFields.length; i++) {
359
+ var f = currentEntity.formFields[i];
360
+ var elx = form.elements[f.key];
361
+ if (!elx) continue;
362
+ if (f.type === "boolean") {
363
+ body[f.key] = elx.checked;
364
+ } else if (f.type === "json" || f.type === "array") {
365
+ try { body[f.key] = JSON.parse(elx.value); } catch (ex) { body[f.key] = elx.value; }
366
+ } else {
367
+ body[f.key] = elx.value;
368
+ }
369
+ }
370
+ try {
371
+ var url = editingId ? BASE_URL + currentEntity.apiPath + "/" + encodeURIComponent(editingId) : BASE_URL + currentEntity.apiPath;
372
+ var method = editingId ? "PUT" : "POST";
373
+ var res = await fetch(url, { method: method, headers: headers(), body: JSON.stringify(body) });
374
+ var data = await res.json();
375
+ if (!res.ok) throw new Error((data.error && data.error.message) || "Request failed");
376
+ toast(editingId ? "Updated" : "Created");
377
+ closeModal();
378
+ refreshTable();
379
+ } catch (e) {
380
+ toast(e.message, "error");
381
+ }
382
+ }
383
+ async function deleteRecord(id) {
384
+ if (!confirm("Delete this record?")) return;
385
+ try {
386
+ var res = await fetch(BASE_URL + currentEntity.apiPath + "/" + encodeURIComponent(id), { method: "DELETE", headers: headers() });
387
+ if (!res.ok) {
388
+ var d = await res.json().catch(function() { return {}; });
389
+ throw new Error((d.error && d.error.message) || "Delete failed");
390
+ }
391
+ toast("Deleted");
392
+ refreshTable();
393
+ } catch (e) {
394
+ toast(e.message, "error");
395
+ }
396
+ }
397
+ function runCapability(endpoint, label) {
398
+ pendingCapability = { endpoint: endpoint, label: label };
399
+ $("cap-modal-title").textContent = label;
400
+ $("cap-modal-desc").textContent = "Run this capability?";
401
+ $("cap-modal").classList.remove("hidden");
402
+ }
403
+ async function confirmCapability() {
404
+ if (!pendingCapability) return;
405
+ closeCapModal();
406
+ try {
407
+ var res = await fetch(BASE_URL + pendingCapability.endpoint, { method: "POST", headers: headers(), body: JSON.stringify({}) });
408
+ var data = await res.json().catch(function() { return {}; });
409
+ if (!res.ok) throw new Error((data.error && data.error.message) || "Failed");
410
+ toast(pendingCapability.label + " completed");
411
+ refreshTable();
412
+ } catch (e) {
413
+ toast(e.message, "error");
414
+ }
415
+ }
416
+ function closeModal() { $("modal").classList.add("hidden"); editingId = null; }
417
+ function closeCapModal() { $("cap-modal").classList.add("hidden"); pendingCapability = null; }
418
+
419
+ // Wire up buttons (no inline onclick anywhere — addEventListener only).
420
+ $("token-input").addEventListener("change", function(e) { setToken(e.target.value); });
421
+ $("create-btn").addEventListener("click", openCreateModal);
422
+ $("refresh-btn").addEventListener("click", refreshTable);
423
+ $("modal-form").addEventListener("submit", submitForm);
424
+ $("modal-close").addEventListener("click", closeModal);
425
+ $("modal-cancel").addEventListener("click", closeModal);
426
+ $("cap-modal-close").addEventListener("click", closeCapModal);
427
+ $("cap-modal-cancel").addEventListener("click", closeCapModal);
428
+ $("cap-modal-confirm").addEventListener("click", confirmCapability);
429
+ $("modal").addEventListener("click", function(e) { if (e.target === e.currentTarget) closeModal(); });
430
+ $("cap-modal").addEventListener("click", function(e) { if (e.target === e.currentTarget) closeCapModal(); });
431
+
432
+ var navButtons = document.querySelectorAll("[id^=nav-]");
433
+ for (var i = 0; i < navButtons.length; i++) {
434
+ (function(btn) {
435
+ btn.addEventListener("click", function() { loadEntity(btn.dataset.entity); });
436
+ })(navButtons[i]);
437
+ }
438
+ })();
439
+ `;
package/src/emit_audit.ts CHANGED
@@ -42,6 +42,19 @@ export function emitAuditMiddleware(system: IR.IRSystem): string {
42
42
  }
43
43
  }
44
44
 
45
+ // Build per-entity redaction lists from @sensitive field annotations.
46
+ // Keyed by the entity name passed to `auditLog(action, entityType)`.
47
+ const sensitiveByEntity: Record<string, string[]> = {};
48
+ for (const mod of system.modules) {
49
+ for (const model of mod.models) {
50
+ const fields = model.fields.filter(f => f.sensitive).map(f => f.name);
51
+ if (fields.length > 0) {
52
+ const existing = sensitiveByEntity[model.name] || [];
53
+ sensitiveByEntity[model.name] = Array.from(new Set([...existing, ...fields]));
54
+ }
55
+ }
56
+ }
57
+
45
58
  lines.push(`// Generated by BoneScript compiler.`);
46
59
  lines.push(`// Audit middleware for: ${system.name} v${system.version}`);
47
60
  if (auditModules.length > 0) {
@@ -56,6 +69,30 @@ export function emitAuditMiddleware(system: IR.IRSystem): string {
56
69
  lines.push(`import { query } from "./db";`);
57
70
  lines.push("");
58
71
 
72
+ // Redaction map: entity name -> set of field names to drop from payload.
73
+ // Computed at compile time from @sensitive annotations on .bone fields.
74
+ lines.push(`// Field redaction map. Fields marked @sensitive in the .bone source are`);
75
+ lines.push(`// stripped before the request body is persisted to audit_log.payload.`);
76
+ lines.push(`const SENSITIVE_FIELDS: Record<string, ReadonlyArray<string>> = ${JSON.stringify(sensitiveByEntity, null, 2)};`);
77
+ lines.push(``);
78
+ lines.push(`// Always-redacted keys regardless of entity. Names are matched case-insensitively.`);
79
+ lines.push(`const ALWAYS_REDACT = new Set(["password", "passwd", "pwd", "secret", "token", "api_key", "apikey", "authorization", "ssn", "card_number", "cvv"]);`);
80
+ lines.push(``);
81
+ lines.push(`function redact(payload: unknown, entityType: string | undefined): unknown {`);
82
+ lines.push(` if (payload == null || typeof payload !== "object") return payload;`);
83
+ lines.push(` const entitySet = new Set(entityType ? SENSITIVE_FIELDS[entityType] || [] : []);`);
84
+ lines.push(` const out: Record<string, unknown> = {};`);
85
+ lines.push(` for (const [k, v] of Object.entries(payload as Record<string, unknown>)) {`);
86
+ lines.push(` if (ALWAYS_REDACT.has(k.toLowerCase()) || entitySet.has(k)) {`);
87
+ lines.push(` out[k] = "[REDACTED]";`);
88
+ lines.push(` } else {`);
89
+ lines.push(` out[k] = v;`);
90
+ lines.push(` }`);
91
+ lines.push(` }`);
92
+ lines.push(` return out;`);
93
+ lines.push(`}`);
94
+ lines.push(``);
95
+
59
96
  lines.push(
60
97
  `export function auditLog(action: string, entityType?: string) {`
61
98
  );
@@ -68,7 +105,7 @@ export function emitAuditMiddleware(system: IR.IRSystem): string {
68
105
  lines.push(
69
106
  ` const entityId = req.params.id ?? (req.body as Record<string, unknown>)?.id ?? null;`
70
107
  );
71
- lines.push(` const payload = req.body ?? null;`);
108
+ lines.push(` const redacted = redact(req.body ?? null, entityType);`);
72
109
  lines.push(` const ipAddress = req.ip ?? null;`);
73
110
  lines.push(` const userAgent = req.headers["user-agent"] ?? null;`);
74
111
  lines.push("");
@@ -81,7 +118,7 @@ export function emitAuditMiddleware(system: IR.IRSystem): string {
81
118
  ` VALUES ($1, $2, $3, $4, $5, $6, $7)\`,`
82
119
  );
83
120
  lines.push(
84
- ` [actorId, action, entityType ?? null, entityId, JSON.stringify(payload), ipAddress, userAgent]`
121
+ ` [actorId, action, entityType ?? null, entityId, JSON.stringify(redacted), ipAddress, userAgent]`
85
122
  );
86
123
  lines.push(` );`);
87
124
  lines.push(` } catch (err) {`);
@@ -98,13 +135,12 @@ export function emitAuditMiddleware(system: IR.IRSystem): string {
98
135
  lines.push(
99
136
  `export async function getAuditLog(entityType: string, entityId: string): Promise<any[]> {`
100
137
  );
101
- lines.push(` const result = await query(`);
138
+ lines.push(` return await query(`);
102
139
  lines.push(
103
140
  ` "SELECT * FROM audit_log WHERE entity_type = $1 AND entity_id = $2 ORDER BY created_at DESC LIMIT 100",`
104
141
  );
105
142
  lines.push(` [entityType, entityId]`);
106
143
  lines.push(` );`);
107
- lines.push(` return result.rows;`);
108
144
  lines.push(`}`);
109
145
  lines.push("");
110
146
 
@@ -172,6 +172,19 @@ function exprToTsInner(expr: Expr): string {
172
172
  return expr.value;
173
173
 
174
174
  case "field":
175
+ // `caller` is a magic identifier that resolves to the authenticated actor.
176
+ // Used by capabilities for ownership checks, e.g. `caller.id == seller.id`.
177
+ // Maps to `auth.actor_id` (the verified `sub` claim from the JWT).
178
+ if (expr.path[0] === "caller") {
179
+ if (expr.path.length === 1) return "auth?.actor_id";
180
+ const tail = expr.path.slice(1);
181
+ // Only `caller.id` is meaningful at the moment; treat any other suffix
182
+ // as a property access on actor_id for forward compatibility.
183
+ if (tail.length === 1 && (tail[0] === "id" || tail[0] === "actor_id")) {
184
+ return "auth?.actor_id";
185
+ }
186
+ return `auth?.actor_id?.${tail.join("?.")}`;
187
+ }
175
188
  // Convert field path to JS property access
176
189
  return expr.path.join("?.");
177
190
 
package/src/emit_full.ts CHANGED
@@ -172,15 +172,22 @@ export class FullEmitter {
172
172
  // 5. Source: main entry point
173
173
  files.push({ path: "src/index.ts", content: emitIndex(system), language: "typescript", source_module: "root" });
174
174
 
175
- // 6. SQL migrations — run schema emitter ONCE, then match by model name
175
+ // 6. SQL migrations — run schema emitter ONCE, then match by model name.
176
+ // Multiple modules (e.g. an api_service AND its backing data_store) can
177
+ // reference the same model. We dedupe by output path so each table only
178
+ // appears once in migrations/ and once in the migrate.ts blocks list.
176
179
  const schemas: string[] = [];
180
+ const seenPaths = new Set<string>();
177
181
  const allSchemaFiles = this.schemaEmitter.emit(system);
178
182
  for (const mod of system.modules) {
179
183
  if (mod.kind === "data_store" || mod.kind === "api_service") {
180
184
  for (const model of mod.models) {
181
185
  const schemaFile = allSchemaFiles.find(f => f.path.includes(toSnakeCase(model.name)) && f.language === "sql");
182
186
  if (schemaFile) {
183
- files.push({ ...schemaFile, path: `migrations/${schemaFile.path.replace("schema/", "")}` });
187
+ const targetPath = `migrations/${schemaFile.path.replace("schema/", "")}`;
188
+ if (seenPaths.has(targetPath)) continue;
189
+ seenPaths.add(targetPath);
190
+ files.push({ ...schemaFile, path: targetPath });
184
191
  schemas.push(schemaFile.content);
185
192
  }
186
193
  }