tempest-express-sdk 0.27.0 → 0.29.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.
package/dist/index.cjs CHANGED
@@ -9461,6 +9461,216 @@ function partitionTotal(partition) {
9461
9461
  return partition.segments.reduce((total, segment) => total + segment.value, 0);
9462
9462
  }
9463
9463
 
9464
+ // src/admin/logs.ts
9465
+ var LIFTED = /* @__PURE__ */ new Set(["level", "logger", "message", "timestamp", "stack"]);
9466
+ function toLogEntry(raw) {
9467
+ const context = {};
9468
+ for (const [key, value] of Object.entries(raw)) {
9469
+ if (LIFTED.has(key) || value === null || value === void 0) continue;
9470
+ context[key] = value;
9471
+ }
9472
+ return {
9473
+ level: typeof raw.level === "string" ? raw.level : "info",
9474
+ logger: typeof raw.logger === "string" ? raw.logger : "",
9475
+ message: typeof raw.message === "string" ? raw.message : "",
9476
+ timestamp: typeof raw.timestamp === "string" ? raw.timestamp : "",
9477
+ stack: typeof raw.stack === "string" ? raw.stack : null,
9478
+ context,
9479
+ raw
9480
+ };
9481
+ }
9482
+ function filterLogEntries(entries, term) {
9483
+ const needle = term.trim().toLowerCase();
9484
+ if (needle === "") return entries;
9485
+ return entries.filter(
9486
+ (entry) => `${entry.message} ${entry.logger} ${entry.stack ?? ""}`.toLowerCase().includes(needle)
9487
+ );
9488
+ }
9489
+ function renderLogEntriesMarkdown(entries, options) {
9490
+ const lines = [
9491
+ "# Application logs",
9492
+ "",
9493
+ `- **Source:** \`${options.source}\``,
9494
+ `- **Search:** ${options.query === "" ? "_none_" : `\`${options.query}\``}`,
9495
+ `- **Exported:** ${entries.length} of ${options.total} matching record(s)`,
9496
+ ""
9497
+ ];
9498
+ if (entries.length < options.total) {
9499
+ lines.push(
9500
+ `> Truncated: the export is capped, so ${options.total - entries.length} older matching record(s) are not included.`,
9501
+ ""
9502
+ );
9503
+ }
9504
+ for (const entry of entries) {
9505
+ lines.push(
9506
+ `## ${entry.level.toUpperCase()} \u2014 ${entry.message || "(no message)"}`,
9507
+ "",
9508
+ `- **When:** ${entry.timestamp || "unknown"}`,
9509
+ `- **Logger:** ${entry.logger || "unknown"}`
9510
+ );
9511
+ for (const [key, value] of Object.entries(entry.context)) {
9512
+ lines.push(
9513
+ `- **${key}:** ${typeof value === "string" ? value : JSON.stringify(value)}`
9514
+ );
9515
+ }
9516
+ lines.push("");
9517
+ if (entry.stack !== null) {
9518
+ lines.push("```text", entry.stack, "```", "");
9519
+ }
9520
+ }
9521
+ return lines.join("\n");
9522
+ }
9523
+ function renderLogEntriesJson(entries) {
9524
+ return JSON.stringify(
9525
+ entries.map((entry) => entry.raw),
9526
+ null,
9527
+ 2
9528
+ );
9529
+ }
9530
+
9531
+ // src/admin/sqlConsole.ts
9532
+ var SqlCapability = {
9533
+ /** `SELECT`, `WITH … SELECT`, `EXPLAIN`, `SHOW`. */
9534
+ READ: "read",
9535
+ /** Adds rows. */
9536
+ INSERT: "insert",
9537
+ /** Changes rows. */
9538
+ UPDATE: "update",
9539
+ /** Removes rows. */
9540
+ DELETE: "delete",
9541
+ /** `CREATE` / `ALTER` / `COMMENT`. */
9542
+ DDL: "ddl",
9543
+ /** `DROP` and `TRUNCATE`: irreversible structure loss. */
9544
+ DROP: "drop",
9545
+ /**
9546
+ * `GRANT` / `REVOKE` / `SET`, and anything the analyser cannot classify.
9547
+ * Unknown statements land here on purpose, so a construct nobody anticipated
9548
+ * needs the most privileged capability rather than the least.
9549
+ */
9550
+ ADMIN: "admin"
9551
+ };
9552
+ var STATEMENT_CAPABILITIES = {
9553
+ select: SqlCapability.READ,
9554
+ with: SqlCapability.READ,
9555
+ explain: SqlCapability.READ,
9556
+ show: SqlCapability.READ,
9557
+ desc: SqlCapability.READ,
9558
+ describe: SqlCapability.READ,
9559
+ insert: SqlCapability.INSERT,
9560
+ replace: SqlCapability.INSERT,
9561
+ update: SqlCapability.UPDATE,
9562
+ delete: SqlCapability.DELETE,
9563
+ create: SqlCapability.DDL,
9564
+ alter: SqlCapability.DDL,
9565
+ comment: SqlCapability.DDL,
9566
+ rename: SqlCapability.DDL,
9567
+ drop: SqlCapability.DROP,
9568
+ truncate: SqlCapability.DROP
9569
+ };
9570
+ var PARSER_HINT = "The admin SQL console needs the optional peer `node-sql-parser`. Install it with: npm install node-sql-parser";
9571
+ async function loadSqlParser() {
9572
+ try {
9573
+ const module = await import('node-sql-parser');
9574
+ const Parser = module.Parser ?? module.default?.Parser;
9575
+ if (Parser === void 0) throw new Error(PARSER_HINT);
9576
+ return new Parser();
9577
+ } catch {
9578
+ throw new Error(PARSER_HINT);
9579
+ }
9580
+ }
9581
+ function analyzeSql(sql5, dialect, parser) {
9582
+ const capabilities = /* @__PURE__ */ new Set();
9583
+ const tables = /* @__PURE__ */ new Set();
9584
+ let statements = 0;
9585
+ let parsed = true;
9586
+ let unscopedWrite = false;
9587
+ try {
9588
+ const ast = parser.astify(sql5, { database: dialect });
9589
+ const list = Array.isArray(ast) ? ast : [ast];
9590
+ statements = list.length;
9591
+ for (const statement of list) {
9592
+ const type = String(statement.type ?? "").toLowerCase();
9593
+ capabilities.add(STATEMENT_CAPABILITIES[type] ?? SqlCapability.ADMIN);
9594
+ if ((type === "update" || type === "delete") && !statement.where) {
9595
+ unscopedWrite = true;
9596
+ }
9597
+ }
9598
+ } catch {
9599
+ parsed = false;
9600
+ statements = 1;
9601
+ capabilities.add(SqlCapability.ADMIN);
9602
+ }
9603
+ try {
9604
+ for (const entry of parser.tableList(sql5, { database: dialect })) {
9605
+ const name = entry.split("::").pop();
9606
+ if (name !== void 0 && name !== "null") tables.add(name.toLowerCase());
9607
+ }
9608
+ } catch {
9609
+ }
9610
+ return {
9611
+ statements,
9612
+ capabilities: [...capabilities],
9613
+ tables: [...tables],
9614
+ parsed,
9615
+ unscopedWrite
9616
+ };
9617
+ }
9618
+ function checkSqlPolicy(analysis, policy) {
9619
+ const granted = new Set(policy.capabilities ?? [SqlCapability.READ]);
9620
+ for (const capability of analysis.capabilities) {
9621
+ if (!granted.has(capability)) {
9622
+ return {
9623
+ allowed: false,
9624
+ reason: analysis.parsed ? `This console may not run ${capability} statements.` : "The statement could not be parsed, so it needs the admin capability."
9625
+ };
9626
+ }
9627
+ }
9628
+ if ((policy.requireWhereOnWrites ?? true) && analysis.unscopedWrite) {
9629
+ return {
9630
+ allowed: false,
9631
+ reason: "An UPDATE or DELETE without a WHERE clause is refused."
9632
+ };
9633
+ }
9634
+ const denied = new Set((policy.denyTables ?? []).map((name) => name.toLowerCase()));
9635
+ for (const table of analysis.tables) {
9636
+ if (denied.has(table)) {
9637
+ return { allowed: false, reason: `Table "${table}" is not available here.` };
9638
+ }
9639
+ }
9640
+ if (policy.allowTables !== void 0) {
9641
+ const allowed = new Set(policy.allowTables.map((name) => name.toLowerCase()));
9642
+ if (analysis.tables.length === 0) {
9643
+ return {
9644
+ allowed: false,
9645
+ reason: "This console only runs statements naming an allowed table."
9646
+ };
9647
+ }
9648
+ for (const table of analysis.tables) {
9649
+ if (!allowed.has(table)) {
9650
+ return { allowed: false, reason: `Table "${table}" is not on the allow list.` };
9651
+ }
9652
+ }
9653
+ }
9654
+ return { allowed: true, reason: null };
9655
+ }
9656
+
9657
+ // src/admin/inlines.ts
9658
+ function adminInline(options) {
9659
+ const slug = options.model.tablename;
9660
+ if (typeof slug !== "string" || slug === "") {
9661
+ throw new Error("adminInline requires a concrete model with a tablename");
9662
+ }
9663
+ return {
9664
+ model: options.model,
9665
+ slug,
9666
+ fkField: options.fkField,
9667
+ listDisplay: options.listDisplay === void 0 ? null : [...options.listDisplay],
9668
+ label: options.label ?? null,
9669
+ editable: options.editable ?? false,
9670
+ canDelete: options.canDelete ?? false
9671
+ };
9672
+ }
9673
+
9464
9674
  // src/admin/lenses.ts
9465
9675
  function slugify(name) {
9466
9676
  return name.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "lens";
@@ -9716,6 +9926,8 @@ var AdminModel = class {
9716
9926
  canImport;
9717
9927
  /** Foreign-key columns rendered as a typed search box. */
9718
9928
  autocompleteFields;
9929
+ /** Related child models listed on the detail view. */
9930
+ inlines;
9719
9931
  actions = /* @__PURE__ */ new Map();
9720
9932
  slugOverride;
9721
9933
  listDisplayOverride;
@@ -9747,6 +9959,7 @@ var AdminModel = class {
9747
9959
  this.uploadStorage = options.uploadStorage ?? null;
9748
9960
  this.canImport = options.canImport ?? false;
9749
9961
  this.autocompleteFields = [...options.autocompleteFields ?? []];
9962
+ this.inlines = [...options.inlines ?? []];
9750
9963
  if (this.uploadFields.length > 0 && this.uploadStorage === null) {
9751
9964
  throw new Error(
9752
9965
  `AdminModel(${this.model.tablename}).uploadFields requires an uploadStorage (e.g. LocalUploadStorage / S3UploadStorage) \u2014 without one there is nowhere to write the file.`
@@ -10025,7 +10238,9 @@ function parseFormBody(admin, body, options = {}) {
10025
10238
  const data = {};
10026
10239
  const errors = {};
10027
10240
  const uploads = options.uploadsAsText === true ? /* @__PURE__ */ new Set() : new Set(admin.uploadFields);
10241
+ const only = options.only === void 0 ? null : new Set(options.only);
10028
10242
  for (const name of admin.editableFieldNames()) {
10243
+ if (only !== null && !only.has(name)) continue;
10029
10244
  const column6 = columns[name];
10030
10245
  if (column6 === void 0) continue;
10031
10246
  if (uploads.has(name)) continue;
@@ -12090,7 +12305,7 @@ function escapeHtml(value) {
12090
12305
  return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
12091
12306
  }
12092
12307
  function renderLayout(context, title, body) {
12093
- const { site, theme, prefix, session, currentPath, navModels, messages } = context;
12308
+ const { site, theme, prefix, session, currentPath, navModels, navSystem, messages } = context;
12094
12309
  const authed = session !== null;
12095
12310
  const indexUrl = `${prefix}/`;
12096
12311
  const navLink = (url, label, active) => `<a href="${escapeHtml(url)}" class="tempest-admin-sidebar__link${active ? " tempest-admin-sidebar__link--active" : ""}">${escapeHtml(label)}</a>`;
@@ -12100,6 +12315,9 @@ function renderLayout(context, title, body) {
12100
12315
  ${navLink(indexUrl, "Dashboard", currentPath === indexUrl)}
12101
12316
  ${navModels.length > 0 ? `<span class="tempest-admin-sidebar__heading">Models</span>${navModels.map(
12102
12317
  (entry) => navLink(entry.url, entry.label, currentPath.startsWith(entry.url))
12318
+ ).join("")}` : ""}
12319
+ ${navSystem.length > 0 ? `<span class="tempest-admin-sidebar__heading">System</span>${navSystem.map(
12320
+ (entry) => navLink(entry.url, entry.label, currentPath.startsWith(entry.url))
12103
12321
  ).join("")}` : ""}
12104
12322
  </nav>
12105
12323
  </aside>` : "";
@@ -12359,15 +12577,102 @@ function renderDetailPage(context, view) {
12359
12577
  </div>
12360
12578
  </header>
12361
12579
  <dl class="tempest-admin-detail__fields">${fields}</dl>
12580
+ ${view.inlines.map((inline) => renderInline(inline, context.session?.csrfToken ?? "", view.inlineError)).join("")}
12362
12581
  ${auditPanel}
12363
12582
  </section>`;
12364
12583
  return renderLayout(context, `${view.title} \xB7 ${view.identity}`, body);
12365
12584
  }
12366
- function renderAuditPanel(audit) {
12367
- const rows = audit.fields.map(
12585
+ function renderInlineCell(field) {
12586
+ const required = field.required ? " required" : "";
12587
+ const name = escapeHtml(field.name);
12588
+ const value = escapeHtml(field.value);
12589
+ let control;
12590
+ switch (field.widget) {
12591
+ case "checkbox":
12592
+ control = `<input type="checkbox" name="${name}" value="true"${field.checked ? " checked" : ""}>`;
12593
+ break;
12594
+ case "textarea":
12595
+ case "json":
12596
+ control = `<textarea name="${name}" rows="2"${required}>${value}</textarea>`;
12597
+ break;
12598
+ case "select": {
12599
+ const blank = field.required ? "" : '<option value="">\u2014 none \u2014</option>';
12600
+ const options = field.options.map(
12601
+ (option) => `<option value="${escapeHtml(option.value)}"${option.value === field.value ? " selected" : ""}>${escapeHtml(option.label)}</option>`
12602
+ ).join("");
12603
+ control = `<select name="${name}"${required}>${blank}${options}</select>`;
12604
+ break;
12605
+ }
12606
+ case "number":
12607
+ control = `<input type="number" name="${name}" value="${value}"${field.step !== null ? ` step="${escapeHtml(field.step)}"` : ""}${required}>`;
12608
+ break;
12609
+ case "datetime":
12610
+ control = `<input type="datetime-local" name="${name}" value="${value}"${required}>`;
12611
+ break;
12612
+ case "date":
12613
+ control = `<input type="date" name="${name}" value="${value}"${required}>`;
12614
+ break;
12615
+ case "time":
12616
+ control = `<input type="time" name="${name}" value="${value}"${required}>`;
12617
+ break;
12618
+ default:
12619
+ control = `<input type="text" name="${name}" value="${value}"${required}>`;
12620
+ }
12621
+ const error = field.error !== null ? `<small class="tempest-admin-form__field-error">${escapeHtml(field.error)}</small>` : "";
12622
+ return `${control}${error}`;
12623
+ }
12624
+ function renderInline(inline, csrfToken, error) {
12625
+ const heading = `<header class="tempest-admin-inline__header">
12626
+ <h2>${escapeHtml(inline.label)}${inline.total > 0 ? ` <span class="tempest-admin-inline__count">(${escapeHtml(inline.total)})</span>` : ""}</h2>
12627
+ ${inline.addUrl !== null ? `<a class="tempest-admin-btn" href="${escapeHtml(inline.addUrl)}">Add</a>` : ""}
12628
+ </header>`;
12629
+ const more = inline.truncated ? `<p class="tempest-admin-inline__more"><em>Showing the first ${escapeHtml(inline.rows.length)} of ${escapeHtml(inline.total)}.</em></p>` : "";
12630
+ if (!inline.editable) {
12631
+ if (inline.rows.length === 0) {
12632
+ return `<section class="tempest-admin-inline">${heading}<p><em>No related records.</em></p></section>`;
12633
+ }
12634
+ const head2 = `<tr>${inline.columns.map((column6) => `<th>${escapeHtml(column6)}</th>`).join("")}<th></th></tr>`;
12635
+ const body = inline.rows.map(
12636
+ (row) => `<tr>${row.cells.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("")}<td>${row.url === null ? "" : `<a href="${escapeHtml(row.url)}">View</a>`}</td></tr>`
12637
+ ).join("");
12638
+ return `<section class="tempest-admin-inline">
12639
+ ${heading}
12640
+ <div class="tempest-admin-inline__scroll">
12641
+ <table class="tempest-admin-inline__table">
12642
+ <thead>${head2}</thead>
12643
+ <tbody>${body}</tbody>
12644
+ </table>
12645
+ </div>
12646
+ ${more}
12647
+ </section>`;
12648
+ }
12649
+ const head = `<tr>${inline.columns.map((column6) => `<th>${escapeHtml(column6)}</th>`).join("")}${inline.canDelete ? "<th>Delete</th>" : ""}</tr>`;
12650
+ const renderRow = (row, isNew) => `<tr${isNew ? ' class="tempest-admin-inline__new"' : ""}>${row.fields.map((field) => `<td>${renderInlineCell(field)}</td>`).join("")}${inline.canDelete ? `<td class="tempest-admin-inline__del">${isNew ? "" : `<input type="checkbox" name="row.${escapeHtml(row.key)}.__delete" value="true">`}</td>` : ""}</tr>`;
12651
+ const rows = inline.rows.map((row) => renderRow(row, false)).join("");
12652
+ const blank = inline.newRow === null ? "" : renderRow(inline.newRow, true);
12653
+ return `<section class="tempest-admin-inline">
12654
+ ${heading}
12655
+ ${error !== null ? `<p class="tempest-admin-form__error">${escapeHtml(error)}</p>` : ""}
12656
+ <form method="post" action="${escapeHtml(inline.formAction)}" class="tempest-admin-inline__form">
12657
+ <input type="hidden" name="csrf_token" value="${escapeHtml(csrfToken)}">
12658
+ <div class="tempest-admin-inline__scroll">
12659
+ <table class="tempest-admin-inline__table">
12660
+ <thead>${head}</thead>
12661
+ <tbody>${rows}${blank}</tbody>
12662
+ </table>
12663
+ </div>
12664
+ <div class="tempest-admin-form__actions">
12665
+ <button type="submit">Save ${escapeHtml(inline.label)}</button>
12666
+ </div>
12667
+ </form>
12668
+ ${more}
12669
+ </section>`;
12670
+ }
12671
+ function renderAuditPanel(audit2) {
12672
+ const rows = audit2.fields.map(
12368
12673
  (field) => `<dt>${escapeHtml(field.label)}</dt><dd>${field.value === "" ? "<em>\u2014</em>" : escapeHtml(field.value)}</dd>`
12369
12674
  ).join("");
12370
- const history = audit.history.length > 0 ? `<ol class="tempest-admin-history">${audit.history.map((entry) => {
12675
+ const history = audit2.history.length > 0 ? `<ol class="tempest-admin-history">${audit2.history.map((entry) => {
12371
12676
  const changes = entry.changes.length > 0 ? `<table class="tempest-admin-history__changes"><thead><tr><th>Field</th><th>Before</th><th>After</th></tr></thead><tbody>${entry.changes.map(
12372
12677
  (change) => `<tr><td>${escapeHtml(change.field)}</td><td>${escapeHtml(change.before)}</td><td>${escapeHtml(change.after)}</td></tr>`
12373
12678
  ).join("")}</tbody></table>` : "<p><em>No field changes recorded.</em></p>";
@@ -12465,6 +12770,86 @@ function renderFormPage(context, view) {
12465
12770
  </section>`;
12466
12771
  return renderLayout(context, `${heading} \xB7 ${context.site.title}`, body);
12467
12772
  }
12773
+ function renderLogsPage(context, view) {
12774
+ const options = view.sources.map(
12775
+ (source) => `<option value="${escapeHtml(source.value)}"${source.selected ? " selected" : ""}>${escapeHtml(source.label)}</option>`
12776
+ ).join("");
12777
+ const rows = view.rows.length > 0 ? view.rows.map((row) => {
12778
+ const context_ = row.context.map(
12779
+ (entry) => `<span class="tempest-admin-logs__meta"><b>${escapeHtml(entry.key)}</b>: ${escapeHtml(entry.value)}</span>`
12780
+ ).join(" ");
12781
+ const message = row.stack === null ? `${escapeHtml(row.message)}${context_ === "" ? "" : `<br>${context_}`}` : `<details><summary>${escapeHtml(row.message)}</summary>${context_ === "" ? "" : `<p>${context_}</p>`}<pre>${escapeHtml(row.stack)}</pre></details>`;
12782
+ return `<tr>
12783
+ <td data-label="Level"><span class="tempest-log-badge tempest-log-badge--${escapeHtml(row.level)}">${escapeHtml(row.level)}</span></td>
12784
+ <td data-label="When">${escapeHtml(row.timestamp)}</td>
12785
+ <td data-label="Logger">${escapeHtml(row.logger)}</td>
12786
+ <td data-label="Message">${message}</td>
12787
+ </tr>`;
12788
+ }).join("") : `<tr><td colspan="4">No log records yet. Point <code>configureFileLogging</code> at the same directory to populate this page.</td></tr>`;
12789
+ const body = `<section class="tempest-admin-logs">
12790
+ <header class="tempest-admin-list__header">
12791
+ <h1>Logs</h1>
12792
+ <p>${escapeHtml(view.total)} record${view.total === 1 ? "" : "s"}.</p>
12793
+ </header>
12794
+ <div class="tempest-admin-list__toolbar">
12795
+ <form method="get" class="tempest-admin-list__filters">
12796
+ <label><span>Source</span><select name="source">${options}</select></label>
12797
+ <input type="search" name="q" value="${escapeHtml(view.query)}" placeholder="Search\u2026" aria-label="Search">
12798
+ <button type="submit">Apply</button>
12799
+ </form>
12800
+ <div class="tempest-admin-list__actions">
12801
+ <a href="${escapeHtml(view.exportMarkdownUrl)}">Export Markdown</a>
12802
+ <a href="${escapeHtml(view.exportJsonUrl)}">Export JSON</a>
12803
+ </div>
12804
+ </div>
12805
+ <div class="tempest-admin-table-wrap">
12806
+ <table class="tempest-admin-list__table">
12807
+ <thead><tr><th>Level</th><th>When</th><th>Logger</th><th>Message</th></tr></thead>
12808
+ <tbody>${rows}</tbody>
12809
+ </table>
12810
+ </div>
12811
+ ${view.pages > 1 ? `<nav class="tempest-admin-list__pagination" aria-label="Pagination">
12812
+ ${view.prevUrl !== null ? `<a href="${escapeHtml(view.prevUrl)}">\u2190 Prev</a>` : ""}
12813
+ <span>Page ${escapeHtml(view.page)} of ${escapeHtml(view.pages)}</span>
12814
+ ${view.nextUrl !== null ? `<a href="${escapeHtml(view.nextUrl)}">Next \u2192</a>` : ""}
12815
+ </nav>` : ""}
12816
+ <p class="tempest-admin-form__hint">Exports carry at most ${escapeHtml(view.exportMax)} records, newest first, honouring the filters above.</p>
12817
+ </section>`;
12818
+ return renderLayout(context, `Logs \xB7 ${context.site.title}`, body);
12819
+ }
12820
+ function renderSqlPage(context, view) {
12821
+ if (context.session === null) throw new Error("The SQL console requires a session");
12822
+ const result = view.rowCount === null ? "" : `<p class="tempest-admin-form__hint">${escapeHtml(view.rowCount)} row${view.rowCount === 1 ? "" : "s"}${view.durationMs === null ? "" : ` in ${escapeHtml(view.durationMs)} ms`}${view.truncated ? " (truncated by the row cap)" : ""}.</p>
12823
+ <div class="tempest-admin-table-wrap">
12824
+ <table class="tempest-admin-list__table">
12825
+ <thead><tr>${view.columns.map((column6) => `<th>${escapeHtml(column6)}</th>`).join("")}</tr></thead>
12826
+ <tbody>${view.rows.map(
12827
+ (row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("")}</tr>`
12828
+ ).join("")}</tbody>
12829
+ </table>
12830
+ </div>`;
12831
+ const body = `<section class="tempest-admin-sql">
12832
+ <header class="tempest-admin-list__header">
12833
+ <h1>SQL console</h1>
12834
+ <p>Capabilities: <code>${escapeHtml(view.capabilities.join(", "))}</code></p>
12835
+ </header>
12836
+ ${view.error !== null ? `<p class="tempest-admin-form__error">${escapeHtml(view.error)}</p>` : ""}
12837
+ <form method="post" action="${escapeHtml(`${context.prefix}/sql`)}" class="tempest-admin-form__form">
12838
+ <input type="hidden" name="csrf_token" value="${escapeHtml(context.session.csrfToken)}">
12839
+ <div class="tempest-admin-form__field">
12840
+ <label>
12841
+ <span>Statement</span>
12842
+ <textarea name="sql" rows="6" spellcheck="false" required>${escapeHtml(view.sql)}</textarea>
12843
+ </label>
12844
+ </div>
12845
+ <div class="tempest-admin-form__actions">
12846
+ <button type="submit">Run</button>
12847
+ </div>
12848
+ </form>
12849
+ ${result}
12850
+ </section>`;
12851
+ return renderLayout(context, `SQL console \xB7 ${context.site.title}`, body);
12852
+ }
12468
12853
  function renderImportPage(context, view) {
12469
12854
  if (context.session === null) throw new Error("The import page requires a session");
12470
12855
  const summary = view.created === null ? "" : `<p class="tempest-admin-import__summary">Created ${escapeHtml(view.created)} record${view.created === 1 ? "" : "s"}.</p>`;
@@ -12550,7 +12935,67 @@ var AUTOCOMPLETE_SCRIPT = `<script>
12550
12935
  });
12551
12936
  })();
12552
12937
  </script>`;
12938
+ function filesFor(dir, source) {
12939
+ if (source === "500") return [path.join(dir, HTTP_500_LOG_FILE)];
12940
+ if (source === "all") return Object.values(LEVEL_LOG_FILES).map((f) => path.join(dir, f));
12941
+ return [path.join(dir, LEVEL_LOG_FILES[source])];
12942
+ }
12943
+ async function readLogEntries(dir, source) {
12944
+ return await readEntries(filesFor(dir, source));
12945
+ }
12946
+ async function readEntries(files) {
12947
+ const entries = [];
12948
+ for (const file of files) {
12949
+ let content;
12950
+ try {
12951
+ content = await promises.readFile(file, "utf8");
12952
+ } catch {
12953
+ continue;
12954
+ }
12955
+ for (const line of content.split("\n")) {
12956
+ if (!line.trim()) continue;
12957
+ try {
12958
+ entries.push(JSON.parse(line));
12959
+ } catch {
12960
+ }
12961
+ }
12962
+ }
12963
+ return entries;
12964
+ }
12965
+ function makeLogsRouter(options) {
12966
+ const router = express3.Router();
12967
+ const path = options.path ?? "/logs";
12968
+ const guards = options.guards ?? [];
12969
+ const handler = (req, res, next) => {
12970
+ const source = req.query.source ?? "all";
12971
+ const validSources = ["all", "debug", "info", "warning", "error", "500"];
12972
+ if (!validSources.includes(source)) {
12973
+ res.status(422).json({ detail: "Invalid log source", code: "VALIDATION_ERROR", details: {} });
12974
+ return;
12975
+ }
12976
+ const page2 = Math.max(1, Number(req.query.page) || 1);
12977
+ const pageSize = Math.min(500, Math.max(1, Number(req.query.pageSize) || 50));
12978
+ readEntries(filesFor(options.dir, source)).then((entries) => {
12979
+ entries.reverse();
12980
+ const total = entries.length;
12981
+ const start = (page2 - 1) * pageSize;
12982
+ res.json({
12983
+ items: entries.slice(start, start + pageSize),
12984
+ total,
12985
+ page: page2,
12986
+ pageSize,
12987
+ pages: Math.ceil(total / pageSize)
12988
+ });
12989
+ }).catch(next);
12990
+ };
12991
+ router.get(path, ...guards, handler);
12992
+ return router;
12993
+ }
12553
12994
  var logger2 = new JSONLogger("tempest_express_sdk.admin.router");
12995
+ var LOG_SOURCES = ["all", "debug", "info", "warning", "error", "500"];
12996
+ var LOG_PAGE_SIZE = 50;
12997
+ var LOG_EXPORT_MAX = 500;
12998
+ var INLINE_ROW_LIMIT = 50;
12554
12999
  var AUTOCOMPLETE_LIMIT = 20;
12555
13000
  var AUDIT_HISTORY_LIMIT = 50;
12556
13001
  var FK_OPTION_CAP = 1e3;
@@ -12581,6 +13026,13 @@ function makeAdminRouter(site, options) {
12581
13026
  const theme = resolveAdminTheme(site.theme);
12582
13027
  const showMetrics = options.showMetrics ?? true;
12583
13028
  const exportMaxRows = options.exportMaxRows ?? 5e3;
13029
+ const systemNav = [];
13030
+ if (options.logDir !== void 0) {
13031
+ systemNav.push({ label: "Logs", url: `${prefix}/logs` });
13032
+ }
13033
+ if (options.sqlConsole !== void 0) {
13034
+ systemNav.push({ label: "SQL console", url: `${prefix}/sql` });
13035
+ }
12584
13036
  const maxUploadBytes = options.maxUploadBytes ?? 10 * 1024 * 1024;
12585
13037
  const sessions = new AdminSessionStore({
12586
13038
  secret: options.secretKey,
@@ -12602,6 +13054,7 @@ function makeAdminRouter(site, options) {
12602
13054
  label: admin.verboseNamePlural(),
12603
13055
  url: `${prefix}/m/${admin.slug()}`
12604
13056
  })),
13057
+ navSystem: systemNav,
12605
13058
  messages: flashFor(req)
12606
13059
  });
12607
13060
  const allows = async (principal, admin, action) => {
@@ -12812,6 +13265,187 @@ function makeAdminRouter(site, options) {
12812
13265
  );
12813
13266
  })
12814
13267
  );
13268
+ if (options.logDir !== void 0) {
13269
+ const logDir = options.logDir;
13270
+ router.get(
13271
+ `${prefix}/logs`,
13272
+ guarded(async (req, res) => {
13273
+ const state = await authenticate(req, res);
13274
+ if (state === null) return;
13275
+ const { entries, source, search } = await readLogs(req, logDir);
13276
+ const page2 = Math.max(1, Number.parseInt(queryString(req.query.page), 10) || 1);
13277
+ const start = (page2 - 1) * LOG_PAGE_SIZE;
13278
+ const pages = Math.max(1, Math.ceil(entries.length / LOG_PAGE_SIZE));
13279
+ const exportUrl = (format) => `${prefix}/logs/export?${buildQuery({ source, q: search, format })}`;
13280
+ const pageUrl = (target) => `?${buildQuery({ source, q: search, page: target })}`;
13281
+ html(
13282
+ res,
13283
+ renderLogsPage(context(req, state.session, state.visible), {
13284
+ sources: LOG_SOURCES.map((value) => ({
13285
+ value,
13286
+ label: value === "500" ? "HTTP 500" : humanizeField(value),
13287
+ selected: value === source
13288
+ })),
13289
+ query: search,
13290
+ rows: entries.slice(start, start + LOG_PAGE_SIZE).map((entry) => ({
13291
+ level: entry.level,
13292
+ timestamp: entry.timestamp,
13293
+ logger: entry.logger,
13294
+ message: entry.message,
13295
+ stack: entry.stack,
13296
+ context: Object.entries(entry.context).map(([key, value]) => ({
13297
+ key,
13298
+ value: typeof value === "string" ? value : JSON.stringify(value)
13299
+ }))
13300
+ })),
13301
+ total: entries.length,
13302
+ page: page2,
13303
+ pages,
13304
+ prevUrl: page2 > 1 ? pageUrl(page2 - 1) : null,
13305
+ nextUrl: page2 < pages ? pageUrl(page2 + 1) : null,
13306
+ exportMarkdownUrl: exportUrl("md"),
13307
+ exportJsonUrl: exportUrl("json"),
13308
+ exportMax: LOG_EXPORT_MAX
13309
+ })
13310
+ );
13311
+ })
13312
+ );
13313
+ router.get(
13314
+ `${prefix}/logs/export`,
13315
+ guarded(async (req, res) => {
13316
+ const state = await authenticate(req, res);
13317
+ if (state === null) return;
13318
+ const format = queryString(req.query.format) === "json" ? "json" : "md";
13319
+ const { entries, source, search } = await readLogs(req, logDir);
13320
+ const window = entries.slice(0, LOG_EXPORT_MAX);
13321
+ const payload = format === "json" ? renderLogEntriesJson(window) : renderLogEntriesMarkdown(window, {
13322
+ source,
13323
+ query: search,
13324
+ total: entries.length
13325
+ });
13326
+ res.status(200).type(format === "json" ? "application/json" : "text/markdown; charset=utf-8").set("content-disposition", `attachment; filename="logs.${format}"`).send(payload);
13327
+ })
13328
+ );
13329
+ }
13330
+ if (options.sqlConsole !== void 0) {
13331
+ const console_ = options.sqlConsole;
13332
+ const policy = console_.policy ?? {};
13333
+ const dialect = console_.dialect ?? "postgresql";
13334
+ const capabilities = policy.capabilities ?? [SqlCapability.READ];
13335
+ const maxRows = policy.maxRows ?? 200;
13336
+ router.get(
13337
+ `${prefix}/sql`,
13338
+ guarded(async (req, res) => {
13339
+ const state = await authenticate(req, res);
13340
+ if (state === null) return;
13341
+ html(
13342
+ res,
13343
+ renderSqlPage(context(req, state.session, state.visible), {
13344
+ sql: "",
13345
+ capabilities: [...capabilities],
13346
+ error: null,
13347
+ columns: [],
13348
+ rows: [],
13349
+ rowCount: null,
13350
+ truncated: false,
13351
+ durationMs: null
13352
+ })
13353
+ );
13354
+ })
13355
+ );
13356
+ router.post(
13357
+ `${prefix}/sql`,
13358
+ guarded(async (req, res) => {
13359
+ const state = await authenticate(req, res);
13360
+ if (state === null) return;
13361
+ if (!checkCsrf(req, res, state)) return;
13362
+ const body = req.body;
13363
+ const sql5 = typeof body.sql === "string" ? body.sql : "";
13364
+ const principal = backend.displayName(state.principal);
13365
+ const render = (view) => {
13366
+ html(
13367
+ res,
13368
+ renderSqlPage(context(req, state.session, state.visible), {
13369
+ sql: sql5,
13370
+ capabilities: [...capabilities],
13371
+ ...view
13372
+ }),
13373
+ view.error === null ? 200 : 400
13374
+ );
13375
+ };
13376
+ const parser = await loadSqlParser();
13377
+ const analysis = analyzeSql(sql5, dialect, parser);
13378
+ const verdict = checkSqlPolicy(analysis, policy);
13379
+ if (!verdict.allowed) {
13380
+ await audit(console_.onAudit, {
13381
+ sql: sql5,
13382
+ principal,
13383
+ allowed: false,
13384
+ reason: verdict.reason,
13385
+ analysis,
13386
+ durationMs: null,
13387
+ rowCount: null
13388
+ });
13389
+ render({
13390
+ error: verdict.reason,
13391
+ columns: [],
13392
+ rows: [],
13393
+ rowCount: null,
13394
+ truncated: false,
13395
+ durationMs: null
13396
+ });
13397
+ return;
13398
+ }
13399
+ const started = Date.now();
13400
+ let rows;
13401
+ try {
13402
+ rows = console_.run === void 0 ? await state.dbSession.raw(sql5).all() : await console_.run(sql5, state.dbSession);
13403
+ } catch (error) {
13404
+ const message = error instanceof Error ? error.message : String(error);
13405
+ await audit(console_.onAudit, {
13406
+ sql: sql5,
13407
+ principal,
13408
+ allowed: true,
13409
+ reason: message,
13410
+ analysis,
13411
+ durationMs: Date.now() - started,
13412
+ rowCount: null
13413
+ });
13414
+ render({
13415
+ error: message,
13416
+ columns: [],
13417
+ rows: [],
13418
+ rowCount: null,
13419
+ truncated: false,
13420
+ durationMs: null
13421
+ });
13422
+ return;
13423
+ }
13424
+ const durationMs = Date.now() - started;
13425
+ await audit(console_.onAudit, {
13426
+ sql: sql5,
13427
+ principal,
13428
+ allowed: true,
13429
+ reason: null,
13430
+ analysis,
13431
+ durationMs,
13432
+ rowCount: rows.length
13433
+ });
13434
+ const window = rows.slice(0, maxRows);
13435
+ const columns = window.length === 0 ? [] : Object.keys(window[0] ?? {});
13436
+ render({
13437
+ error: null,
13438
+ columns,
13439
+ rows: window.map(
13440
+ (row) => columns.map((column6) => formatCellValue(row[column6]))
13441
+ ),
13442
+ rowCount: rows.length,
13443
+ truncated: rows.length > window.length,
13444
+ durationMs
13445
+ });
13446
+ })
13447
+ );
13448
+ }
12815
13449
  router.get(
12816
13450
  `${prefix}/m/:slug`,
12817
13451
  guarded(async (req, res) => {
@@ -13167,6 +13801,8 @@ function makeAdminRouter(site, options) {
13167
13801
  title: admin.verboseName(),
13168
13802
  identity,
13169
13803
  audit: await buildAuditView(admin, row, state.dbSession),
13804
+ inlines: await buildInlines(admin, row, state, identity),
13805
+ inlineError: null,
13170
13806
  fields: admin.detailFieldNames().map((name) => ({ label: name, value: formatCellValue(row[name]) })),
13171
13807
  backUrl: `${prefix}/m/${admin.slug()}`,
13172
13808
  editUrl: await allows(state.principal, admin, AdminPermission.EDIT) ? `${prefix}/m/${admin.slug()}/${identity}/edit` : null,
@@ -13287,6 +13923,103 @@ function makeAdminRouter(site, options) {
13287
13923
  res.redirect(`${prefix}/m/${admin.slug()}/${identity}?ok=updated`);
13288
13924
  })
13289
13925
  );
13926
+ router.post(
13927
+ `${prefix}/m/:slug/:identity/inlines/:child`,
13928
+ guarded(async (req, res) => {
13929
+ const state = await authenticate(req, res);
13930
+ if (state === null) return;
13931
+ const admin = await resolveAdmin(req, res, state);
13932
+ if (admin === null) return;
13933
+ if (!checkCsrf(req, res, state)) return;
13934
+ const childSlug = String(req.params.child);
13935
+ const inline = admin.inlines.find(
13936
+ (entry) => entry.slug === childSlug && entry.editable
13937
+ );
13938
+ const childAdmin = inline === void 0 ? null : site.get(childSlug);
13939
+ if (inline === void 0 || childAdmin === null || !await allows(state.principal, childAdmin, AdminPermission.EDIT)) {
13940
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
13941
+ return;
13942
+ }
13943
+ const identity = String(req.params.identity);
13944
+ const parent = await findRow(admin, state.dbSession, identity);
13945
+ if (parent === null) {
13946
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
13947
+ return;
13948
+ }
13949
+ const parentId = parent[admin.identityField];
13950
+ const { rows: grouped, deletions } = groupInlineSubmission(
13951
+ req.body
13952
+ );
13953
+ const names = inlineFieldNames(childAdmin, inline);
13954
+ const childRepo = childAdmin.repository(state.dbSession);
13955
+ const actorId = backend.principalId(state.principal);
13956
+ const canDelete = inline.canDelete && await allows(state.principal, childAdmin, AdminPermission.DELETE);
13957
+ const failed = [];
13958
+ let formError = null;
13959
+ const owned = async (key) => await childRepo.first({
13960
+ [childAdmin.identityField]: key,
13961
+ [inline.fkField]: parentId
13962
+ });
13963
+ for (const [key, values] of Object.entries(grouped)) {
13964
+ const isNew = key.startsWith("new");
13965
+ if (!isNew && canDelete && deletions.has(key)) {
13966
+ if (await owned(key) !== null) {
13967
+ await childRepo.delete({ [childAdmin.identityField]: key });
13968
+ }
13969
+ continue;
13970
+ }
13971
+ if (isNew && Object.values(values).every((value) => value.trim() === "")) {
13972
+ continue;
13973
+ }
13974
+ const parsed = parseFormBody(childAdmin, values, { only: names });
13975
+ if (Object.keys(parsed.errors).length > 0) {
13976
+ failed.push({ key, values, errors: parsed.errors });
13977
+ continue;
13978
+ }
13979
+ try {
13980
+ if (isNew) {
13981
+ stampActor(childAdmin, parsed.data, actorId, true);
13982
+ await childRepo.create({
13983
+ ...parsed.data,
13984
+ [inline.fkField]: parentId
13985
+ });
13986
+ } else {
13987
+ if (await owned(key) === null) continue;
13988
+ stampActor(childAdmin, parsed.data, actorId, false);
13989
+ await childRepo.update(
13990
+ { [childAdmin.identityField]: key },
13991
+ parsed.data
13992
+ );
13993
+ }
13994
+ } catch (error) {
13995
+ formError = describeWriteFailure(childAdmin, error);
13996
+ failed.push({ key, values, errors: {} });
13997
+ }
13998
+ }
13999
+ if (failed.length > 0) {
14000
+ const fresh = await findRow(admin, state.dbSession, identity) ?? parent;
14001
+ html(
14002
+ res,
14003
+ renderDetailPage(context(req, state.session, state.visible), {
14004
+ title: admin.verboseName(),
14005
+ identity,
14006
+ audit: await buildAuditView(admin, fresh, state.dbSession),
14007
+ inlines: await buildInlines(admin, fresh, state, identity, {
14008
+ [childSlug]: failed
14009
+ }),
14010
+ inlineError: formError ?? "Some inline rows could not be saved.",
14011
+ fields: admin.detailFieldNames().map((name) => ({ label: name, value: formatCellValue(fresh[name]) })),
14012
+ backUrl: `${prefix}/m/${admin.slug()}`,
14013
+ editUrl: await allows(state.principal, admin, AdminPermission.EDIT) ? `${prefix}/m/${admin.slug()}/${identity}/edit` : null,
14014
+ deleteUrl: await allows(state.principal, admin, AdminPermission.DELETE) ? `${prefix}/m/${admin.slug()}/${identity}/delete` : null
14015
+ }),
14016
+ 400
14017
+ );
14018
+ return;
14019
+ }
14020
+ res.redirect(`${prefix}/m/${admin.slug()}/${identity}?ok=updated`);
14021
+ })
14022
+ );
13290
14023
  router.post(
13291
14024
  `${prefix}/m/:slug/:identity/delete`,
13292
14025
  guarded(async (req, res) => {
@@ -13308,6 +14041,89 @@ function makeAdminRouter(site, options) {
13308
14041
  const principal = await backend.loadPrincipal(dbSession, String(actor));
13309
14042
  return principal === null ? String(actor) : backend.displayName(principal);
13310
14043
  }
14044
+ async function buildInlines(admin, parent, state, identity, overrides = {}) {
14045
+ const parentId = parent[admin.identityField];
14046
+ const blocks = [];
14047
+ for (const inline of admin.inlines) {
14048
+ const childAdmin = site.get(inline.slug);
14049
+ const repository = childAdmin === null ? new tempestDbJs.BaseRepository(inline.model, state.dbSession) : childAdmin.repository(state.dbSession);
14050
+ const children = await repository.list({
14051
+ [inline.fkField]: parentId
14052
+ });
14053
+ const columns = inline.listDisplay ?? childAdmin?.listDisplayNames() ?? Object.keys(adminColumns(inline.model));
14054
+ const label = inline.label ?? childAdmin?.verboseNamePlural() ?? humanizeField(inline.slug);
14055
+ const visible = children.slice(0, INLINE_ROW_LIMIT);
14056
+ const editable = inline.editable && childAdmin !== null && await allows(state.principal, childAdmin, AdminPermission.EDIT);
14057
+ const addUrl = childAdmin !== null && await allows(state.principal, childAdmin, AdminPermission.CREATE) ? `${prefix}/m/${inline.slug}/new` : null;
14058
+ if (!editable || childAdmin === null) {
14059
+ blocks.push({
14060
+ label,
14061
+ total: children.length,
14062
+ columns,
14063
+ editable: false,
14064
+ canDelete: false,
14065
+ addUrl,
14066
+ formAction: "",
14067
+ rows: visible.map((child) => ({
14068
+ key: String(child[childAdmin?.identityField ?? "id"]),
14069
+ cells: columns.map((column6) => formatCellValue(child[column6])),
14070
+ fields: [],
14071
+ url: childAdmin === null ? null : `${prefix}/m/${inline.slug}/${String(child[childAdmin.identityField])}`
14072
+ })),
14073
+ newRow: null,
14074
+ truncated: children.length > visible.length
14075
+ });
14076
+ continue;
14077
+ }
14078
+ const names = inlineFieldNames(childAdmin, inline);
14079
+ const submitted = overrides[inline.slug];
14080
+ const rows = [];
14081
+ if (submitted !== void 0) {
14082
+ for (const entry of submitted) {
14083
+ rows.push({
14084
+ key: entry.key,
14085
+ cells: [],
14086
+ fields: inlineFields(
14087
+ childAdmin,
14088
+ names,
14089
+ entry.key,
14090
+ entry.values,
14091
+ entry.errors
14092
+ ),
14093
+ url: null
14094
+ });
14095
+ }
14096
+ } else {
14097
+ for (const child of visible) {
14098
+ const key = String(child[childAdmin.identityField]);
14099
+ rows.push({
14100
+ key,
14101
+ cells: [],
14102
+ fields: inlineFields(childAdmin, names, key, child, {}),
14103
+ url: `${prefix}/m/${inline.slug}/${key}`
14104
+ });
14105
+ }
14106
+ }
14107
+ blocks.push({
14108
+ label,
14109
+ total: children.length,
14110
+ columns: names.map(humanizeField),
14111
+ editable: true,
14112
+ canDelete: inline.canDelete && childAdmin.canDelete,
14113
+ addUrl,
14114
+ formAction: `${prefix}/m/${admin.slug()}/${identity}/inlines/${inline.slug}`,
14115
+ rows,
14116
+ newRow: {
14117
+ key: "new1",
14118
+ cells: [],
14119
+ fields: inlineFields(childAdmin, names, "new1", {}, {}),
14120
+ url: null
14121
+ },
14122
+ truncated: children.length > visible.length
14123
+ });
14124
+ }
14125
+ return blocks;
14126
+ }
13311
14127
  async function buildAuditView(admin, row, dbSession) {
13312
14128
  const fields = [];
13313
14129
  for (const name of admin.auditFieldNames()) {
@@ -13347,6 +14163,15 @@ function makeAdminRouter(site, options) {
13347
14163
  const row = await admin.repository(dbSession).first({ [admin.identityField]: identity });
13348
14164
  return row ?? null;
13349
14165
  }
14166
+ async function readLogs(req, dir) {
14167
+ const requested = queryString(req.query.source);
14168
+ const source = LOG_SOURCES.includes(requested) ? requested : "all";
14169
+ const search = queryString(req.query.q);
14170
+ const raw = await readLogEntries(dir, source);
14171
+ const entries = filterLogEntries(raw.map(toLogEntry), search);
14172
+ entries.reverse();
14173
+ return { entries, source, search };
14174
+ }
13350
14175
  function renderImport(req, state, admin, error, created, rowErrors) {
13351
14176
  return renderImportPage(context(req, state.session, state.visible), {
13352
14177
  title: admin.verboseNamePlural(),
@@ -13606,6 +14431,45 @@ function makeAdminRouter(site, options) {
13606
14431
  }
13607
14432
  return router;
13608
14433
  }
14434
+ function groupInlineSubmission(body) {
14435
+ const rows = {};
14436
+ const deletions = /* @__PURE__ */ new Set();
14437
+ for (const [key, raw] of Object.entries(body)) {
14438
+ if (!key.startsWith("row.")) continue;
14439
+ const parts = key.split(".");
14440
+ if (parts.length !== 3) continue;
14441
+ const [, rowKey, field] = parts;
14442
+ const value = typeof raw === "string" ? raw : "";
14443
+ if (field === "__delete") {
14444
+ if (!["", "false", "off", "0", "no"].includes(value.trim().toLowerCase())) {
14445
+ deletions.add(rowKey);
14446
+ }
14447
+ continue;
14448
+ }
14449
+ const row = rows[rowKey] ?? {};
14450
+ row[field] = value;
14451
+ rows[rowKey] = row;
14452
+ }
14453
+ return { rows, deletions };
14454
+ }
14455
+ function inlineFieldNames(childAdmin, inline) {
14456
+ return childAdmin.editableFieldNames().filter(
14457
+ (name) => name !== inline.fkField && !childAdmin.uploadFields.includes(name) && !childAdmin.autocompleteFields.includes(name)
14458
+ );
14459
+ }
14460
+ function inlineFields(childAdmin, names, key, values, errors) {
14461
+ return buildFormFields(childAdmin, { values, errors }).filter((field) => names.includes(field.name)).map((field) => ({ ...field, name: `row.${key}.${field.name}` }));
14462
+ }
14463
+ async function audit(hook, entry) {
14464
+ if (hook === void 0) return;
14465
+ try {
14466
+ await hook(entry);
14467
+ } catch (error) {
14468
+ logger2.error("Admin SQL audit hook failed", {
14469
+ error: error instanceof Error ? error.message : String(error)
14470
+ });
14471
+ }
14472
+ }
13609
14473
  function flagAllows(admin, action) {
13610
14474
  if (action === AdminPermission.CREATE) return admin.canCreate;
13611
14475
  if (action === AdminPermission.EDIT) return admin.canEdit;
@@ -15692,59 +16556,6 @@ function makeToolSpecRouter(spec, options = {}) {
15692
16556
  });
15693
16557
  return router;
15694
16558
  }
15695
- function filesFor(dir, source) {
15696
- if (source === "500") return [path.join(dir, HTTP_500_LOG_FILE)];
15697
- if (source === "all") return Object.values(LEVEL_LOG_FILES).map((f) => path.join(dir, f));
15698
- return [path.join(dir, LEVEL_LOG_FILES[source])];
15699
- }
15700
- async function readEntries(files) {
15701
- const entries = [];
15702
- for (const file of files) {
15703
- let content;
15704
- try {
15705
- content = await promises.readFile(file, "utf8");
15706
- } catch {
15707
- continue;
15708
- }
15709
- for (const line of content.split("\n")) {
15710
- if (!line.trim()) continue;
15711
- try {
15712
- entries.push(JSON.parse(line));
15713
- } catch {
15714
- }
15715
- }
15716
- }
15717
- return entries;
15718
- }
15719
- function makeLogsRouter(options) {
15720
- const router = express3.Router();
15721
- const path = options.path ?? "/logs";
15722
- const guards = options.guards ?? [];
15723
- const handler = (req, res, next) => {
15724
- const source = req.query.source ?? "all";
15725
- const validSources = ["all", "debug", "info", "warning", "error", "500"];
15726
- if (!validSources.includes(source)) {
15727
- res.status(422).json({ detail: "Invalid log source", code: "VALIDATION_ERROR", details: {} });
15728
- return;
15729
- }
15730
- const page2 = Math.max(1, Number(req.query.page) || 1);
15731
- const pageSize = Math.min(500, Math.max(1, Number(req.query.pageSize) || 50));
15732
- readEntries(filesFor(options.dir, source)).then((entries) => {
15733
- entries.reverse();
15734
- const total = entries.length;
15735
- const start = (page2 - 1) * pageSize;
15736
- res.json({
15737
- items: entries.slice(start, start + pageSize),
15738
- total,
15739
- page: page2,
15740
- pageSize,
15741
- pages: Math.ceil(total / pageSize)
15742
- });
15743
- }).catch(next);
15744
- };
15745
- router.get(path, ...guards, handler);
15746
- return router;
15747
- }
15748
16559
  function createTestDatabase(models) {
15749
16560
  const driver = tempestDbJs.NodeSqliteDriver.open(":memory:");
15750
16561
  for (const model of models) {
@@ -15777,7 +16588,7 @@ async function withTestDatabase(models, fn) {
15777
16588
  }
15778
16589
 
15779
16590
  // src/version.ts
15780
- var VERSION = "0.27.0";
16591
+ var VERSION = "0.29.0";
15781
16592
 
15782
16593
  Object.defineProperty(exports, "OpenAPIRegistry", {
15783
16594
  enumerable: true,
@@ -16014,6 +16825,7 @@ exports.S3UploadStorage = S3UploadStorage;
16014
16825
  exports.SSEBroker = SSEBroker;
16015
16826
  exports.ServerSentEvent = ServerSentEvent;
16016
16827
  exports.SessionService = SessionService;
16828
+ exports.SqlCapability = SqlCapability;
16017
16829
  exports.TOTPHelper = TOTPHelper;
16018
16830
  exports.TaskManager = TaskManager;
16019
16831
  exports.TelegramProvider = TelegramProvider;
@@ -16037,8 +16849,10 @@ exports.activationSchema = activationSchema;
16037
16849
  exports.addLogSink = addLogSink;
16038
16850
  exports.adminAction = adminAction;
16039
16851
  exports.adminColumns = adminColumns;
16852
+ exports.adminInline = adminInline;
16040
16853
  exports.adminLens = adminLens;
16041
16854
  exports.adminThemeCss = adminThemeCss;
16855
+ exports.analyzeSql = analyzeSql;
16042
16856
  exports.attachWebSocketHub = attachWebSocketHub;
16043
16857
  exports.authResponseSchema = authResponseSchema;
16044
16858
  exports.authSettingsShape = authSettingsShape;
@@ -16055,6 +16869,7 @@ exports.buildPaginationLinkHeader = buildPaginationLinkHeader;
16055
16869
  exports.cached = cached3;
16056
16870
  exports.centsField = centsField;
16057
16871
  exports.cepField = cepField;
16872
+ exports.checkSqlPolicy = checkSqlPolicy;
16058
16873
  exports.citiesByUf = citiesByUf;
16059
16874
  exports.cnpjField = cnpjField;
16060
16875
  exports.coerceFlag = coerceFlag;
@@ -16083,6 +16898,7 @@ exports.envBoolean = looseBoolean;
16083
16898
  exports.envList = envList;
16084
16899
  exports.escapeHtml = escapeHtml;
16085
16900
  exports.filterForColumn = filterForColumn;
16901
+ exports.filterLogEntries = filterLogEntries;
16086
16902
  exports.foreignKeyFields = foreignKeyFields;
16087
16903
  exports.foreignKeyLabel = foreignKeyLabel;
16088
16904
  exports.foreignKeyTable = foreignKeyTable;
@@ -16098,6 +16914,7 @@ exports.getConditions = getConditions;
16098
16914
  exports.getPaginationConditions = getPaginationConditions;
16099
16915
  exports.getRequestId = getRequestId;
16100
16916
  exports.getState = getState;
16917
+ exports.groupInlineSubmission = groupInlineSubmission;
16101
16918
  exports.hashOpaqueToken = hashOpaqueToken;
16102
16919
  exports.hexColorField = hexColorField;
16103
16920
  exports.humanizeField = humanizeField;
@@ -16121,6 +16938,7 @@ exports.keyByJwtSubject = keyByJwtSubject;
16121
16938
  exports.latitudeField = latitudeField;
16122
16939
  exports.listStates = listStates;
16123
16940
  exports.loadSettings = loadSettings;
16941
+ exports.loadSqlParser = loadSqlParser;
16124
16942
  exports.logEntrySchema = logEntrySchema;
16125
16943
  exports.logSettingsShape = logSettingsShape;
16126
16944
  exports.loginSchema = loginSchema;
@@ -16181,6 +16999,7 @@ exports.rabbitmqSettingsShape = rabbitmqSettingsShape;
16181
16999
  exports.rateLimitMiddleware = rateLimitMiddleware;
16182
17000
  exports.ratingField = ratingField;
16183
17001
  exports.ratioField = ratioField;
17002
+ exports.readLogEntries = readLogEntries;
16184
17003
  exports.redisSettingsShape = redisSettingsShape;
16185
17004
  exports.refreshSchema = refreshSchema;
16186
17005
  exports.registerExceptionHandlers = registerExceptionHandlers;
@@ -16191,9 +17010,13 @@ exports.renderFormPage = renderFormPage;
16191
17010
  exports.renderImportPage = renderImportPage;
16192
17011
  exports.renderLayout = renderLayout;
16193
17012
  exports.renderListPage = renderListPage;
17013
+ exports.renderLogEntriesJson = renderLogEntriesJson;
17014
+ exports.renderLogEntriesMarkdown = renderLogEntriesMarkdown;
16194
17015
  exports.renderLoginPage = renderLoginPage;
17016
+ exports.renderLogsPage = renderLogsPage;
16195
17017
  exports.renderMfaPage = renderMfaPage;
16196
17018
  exports.renderPasswordResetFormPage = renderPasswordResetFormPage;
17019
+ exports.renderSqlPage = renderSqlPage;
16197
17020
  exports.requestIdMiddleware = requestIdMiddleware;
16198
17021
  exports.requestTracingMiddleware = requestTracingMiddleware;
16199
17022
  exports.requireRoles = requireRoles;
@@ -16217,6 +17040,7 @@ exports.syncFilterSchema = syncFilterSchema;
16217
17040
  exports.syncPaginationSchema = syncPaginationSchema;
16218
17041
  exports.tableNameFor = tableNameFor;
16219
17042
  exports.toDict = toDict;
17043
+ exports.toLogEntry = toLogEntry;
16220
17044
  exports.toUtc = toUtc;
16221
17045
  exports.tokenFromUrl = tokenFromUrl;
16222
17046
  exports.tokenPairSchema = tokenPairSchema;