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.js CHANGED
@@ -1,5 +1,5 @@
1
- import { z, looseBoolean, toDict, PasswordUtils } from './chunk-ADF7OHRH.js';
2
- export { PasswordUtils, VERSION, baseAppSettingsSchema, baseAppSettingsShape, baseResponseSchema, centsField, corsSettingsShape, databaseSettingsShape, looseBoolean as envBoolean, hexColorField, latitudeField, loadSettings, longitudeField, looseBoolean, nonEmptyStrField, nonNegativeFloatField, nonNegativeIntField, percentField, portField, positiveFloatField, positiveIntField, priceField, ratingField, ratioField, serverSettingsShape, slugField, toDict, z } from './chunk-ADF7OHRH.js';
1
+ import { z, looseBoolean, toDict, PasswordUtils } from './chunk-7T4MZJZT.js';
2
+ export { PasswordUtils, VERSION, baseAppSettingsSchema, baseAppSettingsShape, baseResponseSchema, centsField, corsSettingsShape, databaseSettingsShape, looseBoolean as envBoolean, hexColorField, latitudeField, loadSettings, longitudeField, looseBoolean, nonEmptyStrField, nonNegativeFloatField, nonNegativeIntField, percentField, portField, positiveFloatField, positiveIntField, priceField, ratingField, ratioField, serverSettingsShape, slugField, toDict, z } from './chunk-7T4MZJZT.js';
3
3
  import { AsyncLocalStorage } from 'async_hooks';
4
4
  import { Model, column, sql, BaseRepository, RecordNotFound, detectDialect, columnsOf, NodeSqliteDriver, AsyncEngine, or, and } from 'tempest-db-js';
5
5
  export { AsyncEngine, AsyncResult, AsyncSession, BaseRepository, Column, DeleteBuilder, InsertBuilder, Model, NoResultError, NodeSqliteDriver, PostgresDialect, RecordNotFound, SelectBuilder, SqliteDialect, SyncEngine, SyncSession, UpdateBuilder, and, belongsTo, column, columnsOf, createEngine, createSyncEngine, del, detectDialect, getDialect, hasMany, insert, join, loadRelations, not, or, parseDatabaseUrl, select, sql, update } from 'tempest-db-js';
@@ -9334,6 +9334,216 @@ function partitionTotal(partition) {
9334
9334
  return partition.segments.reduce((total, segment) => total + segment.value, 0);
9335
9335
  }
9336
9336
 
9337
+ // src/admin/logs.ts
9338
+ var LIFTED = /* @__PURE__ */ new Set(["level", "logger", "message", "timestamp", "stack"]);
9339
+ function toLogEntry(raw) {
9340
+ const context = {};
9341
+ for (const [key, value] of Object.entries(raw)) {
9342
+ if (LIFTED.has(key) || value === null || value === void 0) continue;
9343
+ context[key] = value;
9344
+ }
9345
+ return {
9346
+ level: typeof raw.level === "string" ? raw.level : "info",
9347
+ logger: typeof raw.logger === "string" ? raw.logger : "",
9348
+ message: typeof raw.message === "string" ? raw.message : "",
9349
+ timestamp: typeof raw.timestamp === "string" ? raw.timestamp : "",
9350
+ stack: typeof raw.stack === "string" ? raw.stack : null,
9351
+ context,
9352
+ raw
9353
+ };
9354
+ }
9355
+ function filterLogEntries(entries, term) {
9356
+ const needle = term.trim().toLowerCase();
9357
+ if (needle === "") return entries;
9358
+ return entries.filter(
9359
+ (entry) => `${entry.message} ${entry.logger} ${entry.stack ?? ""}`.toLowerCase().includes(needle)
9360
+ );
9361
+ }
9362
+ function renderLogEntriesMarkdown(entries, options) {
9363
+ const lines = [
9364
+ "# Application logs",
9365
+ "",
9366
+ `- **Source:** \`${options.source}\``,
9367
+ `- **Search:** ${options.query === "" ? "_none_" : `\`${options.query}\``}`,
9368
+ `- **Exported:** ${entries.length} of ${options.total} matching record(s)`,
9369
+ ""
9370
+ ];
9371
+ if (entries.length < options.total) {
9372
+ lines.push(
9373
+ `> Truncated: the export is capped, so ${options.total - entries.length} older matching record(s) are not included.`,
9374
+ ""
9375
+ );
9376
+ }
9377
+ for (const entry of entries) {
9378
+ lines.push(
9379
+ `## ${entry.level.toUpperCase()} \u2014 ${entry.message || "(no message)"}`,
9380
+ "",
9381
+ `- **When:** ${entry.timestamp || "unknown"}`,
9382
+ `- **Logger:** ${entry.logger || "unknown"}`
9383
+ );
9384
+ for (const [key, value] of Object.entries(entry.context)) {
9385
+ lines.push(
9386
+ `- **${key}:** ${typeof value === "string" ? value : JSON.stringify(value)}`
9387
+ );
9388
+ }
9389
+ lines.push("");
9390
+ if (entry.stack !== null) {
9391
+ lines.push("```text", entry.stack, "```", "");
9392
+ }
9393
+ }
9394
+ return lines.join("\n");
9395
+ }
9396
+ function renderLogEntriesJson(entries) {
9397
+ return JSON.stringify(
9398
+ entries.map((entry) => entry.raw),
9399
+ null,
9400
+ 2
9401
+ );
9402
+ }
9403
+
9404
+ // src/admin/sqlConsole.ts
9405
+ var SqlCapability = {
9406
+ /** `SELECT`, `WITH … SELECT`, `EXPLAIN`, `SHOW`. */
9407
+ READ: "read",
9408
+ /** Adds rows. */
9409
+ INSERT: "insert",
9410
+ /** Changes rows. */
9411
+ UPDATE: "update",
9412
+ /** Removes rows. */
9413
+ DELETE: "delete",
9414
+ /** `CREATE` / `ALTER` / `COMMENT`. */
9415
+ DDL: "ddl",
9416
+ /** `DROP` and `TRUNCATE`: irreversible structure loss. */
9417
+ DROP: "drop",
9418
+ /**
9419
+ * `GRANT` / `REVOKE` / `SET`, and anything the analyser cannot classify.
9420
+ * Unknown statements land here on purpose, so a construct nobody anticipated
9421
+ * needs the most privileged capability rather than the least.
9422
+ */
9423
+ ADMIN: "admin"
9424
+ };
9425
+ var STATEMENT_CAPABILITIES = {
9426
+ select: SqlCapability.READ,
9427
+ with: SqlCapability.READ,
9428
+ explain: SqlCapability.READ,
9429
+ show: SqlCapability.READ,
9430
+ desc: SqlCapability.READ,
9431
+ describe: SqlCapability.READ,
9432
+ insert: SqlCapability.INSERT,
9433
+ replace: SqlCapability.INSERT,
9434
+ update: SqlCapability.UPDATE,
9435
+ delete: SqlCapability.DELETE,
9436
+ create: SqlCapability.DDL,
9437
+ alter: SqlCapability.DDL,
9438
+ comment: SqlCapability.DDL,
9439
+ rename: SqlCapability.DDL,
9440
+ drop: SqlCapability.DROP,
9441
+ truncate: SqlCapability.DROP
9442
+ };
9443
+ var PARSER_HINT = "The admin SQL console needs the optional peer `node-sql-parser`. Install it with: npm install node-sql-parser";
9444
+ async function loadSqlParser() {
9445
+ try {
9446
+ const module = await import('node-sql-parser');
9447
+ const Parser = module.Parser ?? module.default?.Parser;
9448
+ if (Parser === void 0) throw new Error(PARSER_HINT);
9449
+ return new Parser();
9450
+ } catch {
9451
+ throw new Error(PARSER_HINT);
9452
+ }
9453
+ }
9454
+ function analyzeSql(sql5, dialect, parser) {
9455
+ const capabilities = /* @__PURE__ */ new Set();
9456
+ const tables = /* @__PURE__ */ new Set();
9457
+ let statements = 0;
9458
+ let parsed = true;
9459
+ let unscopedWrite = false;
9460
+ try {
9461
+ const ast = parser.astify(sql5, { database: dialect });
9462
+ const list = Array.isArray(ast) ? ast : [ast];
9463
+ statements = list.length;
9464
+ for (const statement of list) {
9465
+ const type = String(statement.type ?? "").toLowerCase();
9466
+ capabilities.add(STATEMENT_CAPABILITIES[type] ?? SqlCapability.ADMIN);
9467
+ if ((type === "update" || type === "delete") && !statement.where) {
9468
+ unscopedWrite = true;
9469
+ }
9470
+ }
9471
+ } catch {
9472
+ parsed = false;
9473
+ statements = 1;
9474
+ capabilities.add(SqlCapability.ADMIN);
9475
+ }
9476
+ try {
9477
+ for (const entry of parser.tableList(sql5, { database: dialect })) {
9478
+ const name = entry.split("::").pop();
9479
+ if (name !== void 0 && name !== "null") tables.add(name.toLowerCase());
9480
+ }
9481
+ } catch {
9482
+ }
9483
+ return {
9484
+ statements,
9485
+ capabilities: [...capabilities],
9486
+ tables: [...tables],
9487
+ parsed,
9488
+ unscopedWrite
9489
+ };
9490
+ }
9491
+ function checkSqlPolicy(analysis, policy) {
9492
+ const granted = new Set(policy.capabilities ?? [SqlCapability.READ]);
9493
+ for (const capability of analysis.capabilities) {
9494
+ if (!granted.has(capability)) {
9495
+ return {
9496
+ allowed: false,
9497
+ reason: analysis.parsed ? `This console may not run ${capability} statements.` : "The statement could not be parsed, so it needs the admin capability."
9498
+ };
9499
+ }
9500
+ }
9501
+ if ((policy.requireWhereOnWrites ?? true) && analysis.unscopedWrite) {
9502
+ return {
9503
+ allowed: false,
9504
+ reason: "An UPDATE or DELETE without a WHERE clause is refused."
9505
+ };
9506
+ }
9507
+ const denied = new Set((policy.denyTables ?? []).map((name) => name.toLowerCase()));
9508
+ for (const table of analysis.tables) {
9509
+ if (denied.has(table)) {
9510
+ return { allowed: false, reason: `Table "${table}" is not available here.` };
9511
+ }
9512
+ }
9513
+ if (policy.allowTables !== void 0) {
9514
+ const allowed = new Set(policy.allowTables.map((name) => name.toLowerCase()));
9515
+ if (analysis.tables.length === 0) {
9516
+ return {
9517
+ allowed: false,
9518
+ reason: "This console only runs statements naming an allowed table."
9519
+ };
9520
+ }
9521
+ for (const table of analysis.tables) {
9522
+ if (!allowed.has(table)) {
9523
+ return { allowed: false, reason: `Table "${table}" is not on the allow list.` };
9524
+ }
9525
+ }
9526
+ }
9527
+ return { allowed: true, reason: null };
9528
+ }
9529
+
9530
+ // src/admin/inlines.ts
9531
+ function adminInline(options) {
9532
+ const slug = options.model.tablename;
9533
+ if (typeof slug !== "string" || slug === "") {
9534
+ throw new Error("adminInline requires a concrete model with a tablename");
9535
+ }
9536
+ return {
9537
+ model: options.model,
9538
+ slug,
9539
+ fkField: options.fkField,
9540
+ listDisplay: options.listDisplay === void 0 ? null : [...options.listDisplay],
9541
+ label: options.label ?? null,
9542
+ editable: options.editable ?? false,
9543
+ canDelete: options.canDelete ?? false
9544
+ };
9545
+ }
9546
+
9337
9547
  // src/admin/lenses.ts
9338
9548
  function slugify(name) {
9339
9549
  return name.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "lens";
@@ -9589,6 +9799,8 @@ var AdminModel = class {
9589
9799
  canImport;
9590
9800
  /** Foreign-key columns rendered as a typed search box. */
9591
9801
  autocompleteFields;
9802
+ /** Related child models listed on the detail view. */
9803
+ inlines;
9592
9804
  actions = /* @__PURE__ */ new Map();
9593
9805
  slugOverride;
9594
9806
  listDisplayOverride;
@@ -9620,6 +9832,7 @@ var AdminModel = class {
9620
9832
  this.uploadStorage = options.uploadStorage ?? null;
9621
9833
  this.canImport = options.canImport ?? false;
9622
9834
  this.autocompleteFields = [...options.autocompleteFields ?? []];
9835
+ this.inlines = [...options.inlines ?? []];
9623
9836
  if (this.uploadFields.length > 0 && this.uploadStorage === null) {
9624
9837
  throw new Error(
9625
9838
  `AdminModel(${this.model.tablename}).uploadFields requires an uploadStorage (e.g. LocalUploadStorage / S3UploadStorage) \u2014 without one there is nowhere to write the file.`
@@ -9898,7 +10111,9 @@ function parseFormBody(admin, body, options = {}) {
9898
10111
  const data = {};
9899
10112
  const errors = {};
9900
10113
  const uploads = options.uploadsAsText === true ? /* @__PURE__ */ new Set() : new Set(admin.uploadFields);
10114
+ const only = options.only === void 0 ? null : new Set(options.only);
9901
10115
  for (const name of admin.editableFieldNames()) {
10116
+ if (only !== null && !only.has(name)) continue;
9902
10117
  const column6 = columns[name];
9903
10118
  if (column6 === void 0) continue;
9904
10119
  if (uploads.has(name)) continue;
@@ -11963,7 +12178,7 @@ function escapeHtml(value) {
11963
12178
  return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
11964
12179
  }
11965
12180
  function renderLayout(context, title, body) {
11966
- const { site, theme, prefix, session, currentPath, navModels, messages } = context;
12181
+ const { site, theme, prefix, session, currentPath, navModels, navSystem, messages } = context;
11967
12182
  const authed = session !== null;
11968
12183
  const indexUrl = `${prefix}/`;
11969
12184
  const navLink = (url, label, active) => `<a href="${escapeHtml(url)}" class="tempest-admin-sidebar__link${active ? " tempest-admin-sidebar__link--active" : ""}">${escapeHtml(label)}</a>`;
@@ -11973,6 +12188,9 @@ function renderLayout(context, title, body) {
11973
12188
  ${navLink(indexUrl, "Dashboard", currentPath === indexUrl)}
11974
12189
  ${navModels.length > 0 ? `<span class="tempest-admin-sidebar__heading">Models</span>${navModels.map(
11975
12190
  (entry) => navLink(entry.url, entry.label, currentPath.startsWith(entry.url))
12191
+ ).join("")}` : ""}
12192
+ ${navSystem.length > 0 ? `<span class="tempest-admin-sidebar__heading">System</span>${navSystem.map(
12193
+ (entry) => navLink(entry.url, entry.label, currentPath.startsWith(entry.url))
11976
12194
  ).join("")}` : ""}
11977
12195
  </nav>
11978
12196
  </aside>` : "";
@@ -12232,15 +12450,102 @@ function renderDetailPage(context, view) {
12232
12450
  </div>
12233
12451
  </header>
12234
12452
  <dl class="tempest-admin-detail__fields">${fields}</dl>
12453
+ ${view.inlines.map((inline) => renderInline(inline, context.session?.csrfToken ?? "", view.inlineError)).join("")}
12235
12454
  ${auditPanel}
12236
12455
  </section>`;
12237
12456
  return renderLayout(context, `${view.title} \xB7 ${view.identity}`, body);
12238
12457
  }
12239
- function renderAuditPanel(audit) {
12240
- const rows = audit.fields.map(
12458
+ function renderInlineCell(field) {
12459
+ const required = field.required ? " required" : "";
12460
+ const name = escapeHtml(field.name);
12461
+ const value = escapeHtml(field.value);
12462
+ let control;
12463
+ switch (field.widget) {
12464
+ case "checkbox":
12465
+ control = `<input type="checkbox" name="${name}" value="true"${field.checked ? " checked" : ""}>`;
12466
+ break;
12467
+ case "textarea":
12468
+ case "json":
12469
+ control = `<textarea name="${name}" rows="2"${required}>${value}</textarea>`;
12470
+ break;
12471
+ case "select": {
12472
+ const blank = field.required ? "" : '<option value="">\u2014 none \u2014</option>';
12473
+ const options = field.options.map(
12474
+ (option) => `<option value="${escapeHtml(option.value)}"${option.value === field.value ? " selected" : ""}>${escapeHtml(option.label)}</option>`
12475
+ ).join("");
12476
+ control = `<select name="${name}"${required}>${blank}${options}</select>`;
12477
+ break;
12478
+ }
12479
+ case "number":
12480
+ control = `<input type="number" name="${name}" value="${value}"${field.step !== null ? ` step="${escapeHtml(field.step)}"` : ""}${required}>`;
12481
+ break;
12482
+ case "datetime":
12483
+ control = `<input type="datetime-local" name="${name}" value="${value}"${required}>`;
12484
+ break;
12485
+ case "date":
12486
+ control = `<input type="date" name="${name}" value="${value}"${required}>`;
12487
+ break;
12488
+ case "time":
12489
+ control = `<input type="time" name="${name}" value="${value}"${required}>`;
12490
+ break;
12491
+ default:
12492
+ control = `<input type="text" name="${name}" value="${value}"${required}>`;
12493
+ }
12494
+ const error = field.error !== null ? `<small class="tempest-admin-form__field-error">${escapeHtml(field.error)}</small>` : "";
12495
+ return `${control}${error}`;
12496
+ }
12497
+ function renderInline(inline, csrfToken, error) {
12498
+ const heading = `<header class="tempest-admin-inline__header">
12499
+ <h2>${escapeHtml(inline.label)}${inline.total > 0 ? ` <span class="tempest-admin-inline__count">(${escapeHtml(inline.total)})</span>` : ""}</h2>
12500
+ ${inline.addUrl !== null ? `<a class="tempest-admin-btn" href="${escapeHtml(inline.addUrl)}">Add</a>` : ""}
12501
+ </header>`;
12502
+ const more = inline.truncated ? `<p class="tempest-admin-inline__more"><em>Showing the first ${escapeHtml(inline.rows.length)} of ${escapeHtml(inline.total)}.</em></p>` : "";
12503
+ if (!inline.editable) {
12504
+ if (inline.rows.length === 0) {
12505
+ return `<section class="tempest-admin-inline">${heading}<p><em>No related records.</em></p></section>`;
12506
+ }
12507
+ const head2 = `<tr>${inline.columns.map((column6) => `<th>${escapeHtml(column6)}</th>`).join("")}<th></th></tr>`;
12508
+ const body = inline.rows.map(
12509
+ (row) => `<tr>${row.cells.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("")}<td>${row.url === null ? "" : `<a href="${escapeHtml(row.url)}">View</a>`}</td></tr>`
12510
+ ).join("");
12511
+ return `<section class="tempest-admin-inline">
12512
+ ${heading}
12513
+ <div class="tempest-admin-inline__scroll">
12514
+ <table class="tempest-admin-inline__table">
12515
+ <thead>${head2}</thead>
12516
+ <tbody>${body}</tbody>
12517
+ </table>
12518
+ </div>
12519
+ ${more}
12520
+ </section>`;
12521
+ }
12522
+ const head = `<tr>${inline.columns.map((column6) => `<th>${escapeHtml(column6)}</th>`).join("")}${inline.canDelete ? "<th>Delete</th>" : ""}</tr>`;
12523
+ 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>`;
12524
+ const rows = inline.rows.map((row) => renderRow(row, false)).join("");
12525
+ const blank = inline.newRow === null ? "" : renderRow(inline.newRow, true);
12526
+ return `<section class="tempest-admin-inline">
12527
+ ${heading}
12528
+ ${error !== null ? `<p class="tempest-admin-form__error">${escapeHtml(error)}</p>` : ""}
12529
+ <form method="post" action="${escapeHtml(inline.formAction)}" class="tempest-admin-inline__form">
12530
+ <input type="hidden" name="csrf_token" value="${escapeHtml(csrfToken)}">
12531
+ <div class="tempest-admin-inline__scroll">
12532
+ <table class="tempest-admin-inline__table">
12533
+ <thead>${head}</thead>
12534
+ <tbody>${rows}${blank}</tbody>
12535
+ </table>
12536
+ </div>
12537
+ <div class="tempest-admin-form__actions">
12538
+ <button type="submit">Save ${escapeHtml(inline.label)}</button>
12539
+ </div>
12540
+ </form>
12541
+ ${more}
12542
+ </section>`;
12543
+ }
12544
+ function renderAuditPanel(audit2) {
12545
+ const rows = audit2.fields.map(
12241
12546
  (field) => `<dt>${escapeHtml(field.label)}</dt><dd>${field.value === "" ? "<em>\u2014</em>" : escapeHtml(field.value)}</dd>`
12242
12547
  ).join("");
12243
- const history = audit.history.length > 0 ? `<ol class="tempest-admin-history">${audit.history.map((entry) => {
12548
+ const history = audit2.history.length > 0 ? `<ol class="tempest-admin-history">${audit2.history.map((entry) => {
12244
12549
  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(
12245
12550
  (change) => `<tr><td>${escapeHtml(change.field)}</td><td>${escapeHtml(change.before)}</td><td>${escapeHtml(change.after)}</td></tr>`
12246
12551
  ).join("")}</tbody></table>` : "<p><em>No field changes recorded.</em></p>";
@@ -12338,6 +12643,86 @@ function renderFormPage(context, view) {
12338
12643
  </section>`;
12339
12644
  return renderLayout(context, `${heading} \xB7 ${context.site.title}`, body);
12340
12645
  }
12646
+ function renderLogsPage(context, view) {
12647
+ const options = view.sources.map(
12648
+ (source) => `<option value="${escapeHtml(source.value)}"${source.selected ? " selected" : ""}>${escapeHtml(source.label)}</option>`
12649
+ ).join("");
12650
+ const rows = view.rows.length > 0 ? view.rows.map((row) => {
12651
+ const context_ = row.context.map(
12652
+ (entry) => `<span class="tempest-admin-logs__meta"><b>${escapeHtml(entry.key)}</b>: ${escapeHtml(entry.value)}</span>`
12653
+ ).join(" ");
12654
+ 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>`;
12655
+ return `<tr>
12656
+ <td data-label="Level"><span class="tempest-log-badge tempest-log-badge--${escapeHtml(row.level)}">${escapeHtml(row.level)}</span></td>
12657
+ <td data-label="When">${escapeHtml(row.timestamp)}</td>
12658
+ <td data-label="Logger">${escapeHtml(row.logger)}</td>
12659
+ <td data-label="Message">${message}</td>
12660
+ </tr>`;
12661
+ }).join("") : `<tr><td colspan="4">No log records yet. Point <code>configureFileLogging</code> at the same directory to populate this page.</td></tr>`;
12662
+ const body = `<section class="tempest-admin-logs">
12663
+ <header class="tempest-admin-list__header">
12664
+ <h1>Logs</h1>
12665
+ <p>${escapeHtml(view.total)} record${view.total === 1 ? "" : "s"}.</p>
12666
+ </header>
12667
+ <div class="tempest-admin-list__toolbar">
12668
+ <form method="get" class="tempest-admin-list__filters">
12669
+ <label><span>Source</span><select name="source">${options}</select></label>
12670
+ <input type="search" name="q" value="${escapeHtml(view.query)}" placeholder="Search\u2026" aria-label="Search">
12671
+ <button type="submit">Apply</button>
12672
+ </form>
12673
+ <div class="tempest-admin-list__actions">
12674
+ <a href="${escapeHtml(view.exportMarkdownUrl)}">Export Markdown</a>
12675
+ <a href="${escapeHtml(view.exportJsonUrl)}">Export JSON</a>
12676
+ </div>
12677
+ </div>
12678
+ <div class="tempest-admin-table-wrap">
12679
+ <table class="tempest-admin-list__table">
12680
+ <thead><tr><th>Level</th><th>When</th><th>Logger</th><th>Message</th></tr></thead>
12681
+ <tbody>${rows}</tbody>
12682
+ </table>
12683
+ </div>
12684
+ ${view.pages > 1 ? `<nav class="tempest-admin-list__pagination" aria-label="Pagination">
12685
+ ${view.prevUrl !== null ? `<a href="${escapeHtml(view.prevUrl)}">\u2190 Prev</a>` : ""}
12686
+ <span>Page ${escapeHtml(view.page)} of ${escapeHtml(view.pages)}</span>
12687
+ ${view.nextUrl !== null ? `<a href="${escapeHtml(view.nextUrl)}">Next \u2192</a>` : ""}
12688
+ </nav>` : ""}
12689
+ <p class="tempest-admin-form__hint">Exports carry at most ${escapeHtml(view.exportMax)} records, newest first, honouring the filters above.</p>
12690
+ </section>`;
12691
+ return renderLayout(context, `Logs \xB7 ${context.site.title}`, body);
12692
+ }
12693
+ function renderSqlPage(context, view) {
12694
+ if (context.session === null) throw new Error("The SQL console requires a session");
12695
+ 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>
12696
+ <div class="tempest-admin-table-wrap">
12697
+ <table class="tempest-admin-list__table">
12698
+ <thead><tr>${view.columns.map((column6) => `<th>${escapeHtml(column6)}</th>`).join("")}</tr></thead>
12699
+ <tbody>${view.rows.map(
12700
+ (row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("")}</tr>`
12701
+ ).join("")}</tbody>
12702
+ </table>
12703
+ </div>`;
12704
+ const body = `<section class="tempest-admin-sql">
12705
+ <header class="tempest-admin-list__header">
12706
+ <h1>SQL console</h1>
12707
+ <p>Capabilities: <code>${escapeHtml(view.capabilities.join(", "))}</code></p>
12708
+ </header>
12709
+ ${view.error !== null ? `<p class="tempest-admin-form__error">${escapeHtml(view.error)}</p>` : ""}
12710
+ <form method="post" action="${escapeHtml(`${context.prefix}/sql`)}" class="tempest-admin-form__form">
12711
+ <input type="hidden" name="csrf_token" value="${escapeHtml(context.session.csrfToken)}">
12712
+ <div class="tempest-admin-form__field">
12713
+ <label>
12714
+ <span>Statement</span>
12715
+ <textarea name="sql" rows="6" spellcheck="false" required>${escapeHtml(view.sql)}</textarea>
12716
+ </label>
12717
+ </div>
12718
+ <div class="tempest-admin-form__actions">
12719
+ <button type="submit">Run</button>
12720
+ </div>
12721
+ </form>
12722
+ ${result}
12723
+ </section>`;
12724
+ return renderLayout(context, `SQL console \xB7 ${context.site.title}`, body);
12725
+ }
12341
12726
  function renderImportPage(context, view) {
12342
12727
  if (context.session === null) throw new Error("The import page requires a session");
12343
12728
  const summary = view.created === null ? "" : `<p class="tempest-admin-import__summary">Created ${escapeHtml(view.created)} record${view.created === 1 ? "" : "s"}.</p>`;
@@ -12423,7 +12808,67 @@ var AUTOCOMPLETE_SCRIPT = `<script>
12423
12808
  });
12424
12809
  })();
12425
12810
  </script>`;
12811
+ function filesFor(dir, source) {
12812
+ if (source === "500") return [join(dir, HTTP_500_LOG_FILE)];
12813
+ if (source === "all") return Object.values(LEVEL_LOG_FILES).map((f) => join(dir, f));
12814
+ return [join(dir, LEVEL_LOG_FILES[source])];
12815
+ }
12816
+ async function readLogEntries(dir, source) {
12817
+ return await readEntries(filesFor(dir, source));
12818
+ }
12819
+ async function readEntries(files) {
12820
+ const entries = [];
12821
+ for (const file of files) {
12822
+ let content;
12823
+ try {
12824
+ content = await readFile(file, "utf8");
12825
+ } catch {
12826
+ continue;
12827
+ }
12828
+ for (const line of content.split("\n")) {
12829
+ if (!line.trim()) continue;
12830
+ try {
12831
+ entries.push(JSON.parse(line));
12832
+ } catch {
12833
+ }
12834
+ }
12835
+ }
12836
+ return entries;
12837
+ }
12838
+ function makeLogsRouter(options) {
12839
+ const router = Router();
12840
+ const path = options.path ?? "/logs";
12841
+ const guards = options.guards ?? [];
12842
+ const handler = (req, res, next) => {
12843
+ const source = req.query.source ?? "all";
12844
+ const validSources = ["all", "debug", "info", "warning", "error", "500"];
12845
+ if (!validSources.includes(source)) {
12846
+ res.status(422).json({ detail: "Invalid log source", code: "VALIDATION_ERROR", details: {} });
12847
+ return;
12848
+ }
12849
+ const page2 = Math.max(1, Number(req.query.page) || 1);
12850
+ const pageSize = Math.min(500, Math.max(1, Number(req.query.pageSize) || 50));
12851
+ readEntries(filesFor(options.dir, source)).then((entries) => {
12852
+ entries.reverse();
12853
+ const total = entries.length;
12854
+ const start = (page2 - 1) * pageSize;
12855
+ res.json({
12856
+ items: entries.slice(start, start + pageSize),
12857
+ total,
12858
+ page: page2,
12859
+ pageSize,
12860
+ pages: Math.ceil(total / pageSize)
12861
+ });
12862
+ }).catch(next);
12863
+ };
12864
+ router.get(path, ...guards, handler);
12865
+ return router;
12866
+ }
12426
12867
  var logger2 = new JSONLogger("tempest_express_sdk.admin.router");
12868
+ var LOG_SOURCES = ["all", "debug", "info", "warning", "error", "500"];
12869
+ var LOG_PAGE_SIZE = 50;
12870
+ var LOG_EXPORT_MAX = 500;
12871
+ var INLINE_ROW_LIMIT = 50;
12427
12872
  var AUTOCOMPLETE_LIMIT = 20;
12428
12873
  var AUDIT_HISTORY_LIMIT = 50;
12429
12874
  var FK_OPTION_CAP = 1e3;
@@ -12454,6 +12899,13 @@ function makeAdminRouter(site, options) {
12454
12899
  const theme = resolveAdminTheme(site.theme);
12455
12900
  const showMetrics = options.showMetrics ?? true;
12456
12901
  const exportMaxRows = options.exportMaxRows ?? 5e3;
12902
+ const systemNav = [];
12903
+ if (options.logDir !== void 0) {
12904
+ systemNav.push({ label: "Logs", url: `${prefix}/logs` });
12905
+ }
12906
+ if (options.sqlConsole !== void 0) {
12907
+ systemNav.push({ label: "SQL console", url: `${prefix}/sql` });
12908
+ }
12457
12909
  const maxUploadBytes = options.maxUploadBytes ?? 10 * 1024 * 1024;
12458
12910
  const sessions = new AdminSessionStore({
12459
12911
  secret: options.secretKey,
@@ -12475,6 +12927,7 @@ function makeAdminRouter(site, options) {
12475
12927
  label: admin.verboseNamePlural(),
12476
12928
  url: `${prefix}/m/${admin.slug()}`
12477
12929
  })),
12930
+ navSystem: systemNav,
12478
12931
  messages: flashFor(req)
12479
12932
  });
12480
12933
  const allows = async (principal, admin, action) => {
@@ -12685,6 +13138,187 @@ function makeAdminRouter(site, options) {
12685
13138
  );
12686
13139
  })
12687
13140
  );
13141
+ if (options.logDir !== void 0) {
13142
+ const logDir = options.logDir;
13143
+ router.get(
13144
+ `${prefix}/logs`,
13145
+ guarded(async (req, res) => {
13146
+ const state = await authenticate(req, res);
13147
+ if (state === null) return;
13148
+ const { entries, source, search } = await readLogs(req, logDir);
13149
+ const page2 = Math.max(1, Number.parseInt(queryString(req.query.page), 10) || 1);
13150
+ const start = (page2 - 1) * LOG_PAGE_SIZE;
13151
+ const pages = Math.max(1, Math.ceil(entries.length / LOG_PAGE_SIZE));
13152
+ const exportUrl = (format) => `${prefix}/logs/export?${buildQuery({ source, q: search, format })}`;
13153
+ const pageUrl = (target) => `?${buildQuery({ source, q: search, page: target })}`;
13154
+ html(
13155
+ res,
13156
+ renderLogsPage(context(req, state.session, state.visible), {
13157
+ sources: LOG_SOURCES.map((value) => ({
13158
+ value,
13159
+ label: value === "500" ? "HTTP 500" : humanizeField(value),
13160
+ selected: value === source
13161
+ })),
13162
+ query: search,
13163
+ rows: entries.slice(start, start + LOG_PAGE_SIZE).map((entry) => ({
13164
+ level: entry.level,
13165
+ timestamp: entry.timestamp,
13166
+ logger: entry.logger,
13167
+ message: entry.message,
13168
+ stack: entry.stack,
13169
+ context: Object.entries(entry.context).map(([key, value]) => ({
13170
+ key,
13171
+ value: typeof value === "string" ? value : JSON.stringify(value)
13172
+ }))
13173
+ })),
13174
+ total: entries.length,
13175
+ page: page2,
13176
+ pages,
13177
+ prevUrl: page2 > 1 ? pageUrl(page2 - 1) : null,
13178
+ nextUrl: page2 < pages ? pageUrl(page2 + 1) : null,
13179
+ exportMarkdownUrl: exportUrl("md"),
13180
+ exportJsonUrl: exportUrl("json"),
13181
+ exportMax: LOG_EXPORT_MAX
13182
+ })
13183
+ );
13184
+ })
13185
+ );
13186
+ router.get(
13187
+ `${prefix}/logs/export`,
13188
+ guarded(async (req, res) => {
13189
+ const state = await authenticate(req, res);
13190
+ if (state === null) return;
13191
+ const format = queryString(req.query.format) === "json" ? "json" : "md";
13192
+ const { entries, source, search } = await readLogs(req, logDir);
13193
+ const window = entries.slice(0, LOG_EXPORT_MAX);
13194
+ const payload = format === "json" ? renderLogEntriesJson(window) : renderLogEntriesMarkdown(window, {
13195
+ source,
13196
+ query: search,
13197
+ total: entries.length
13198
+ });
13199
+ res.status(200).type(format === "json" ? "application/json" : "text/markdown; charset=utf-8").set("content-disposition", `attachment; filename="logs.${format}"`).send(payload);
13200
+ })
13201
+ );
13202
+ }
13203
+ if (options.sqlConsole !== void 0) {
13204
+ const console_ = options.sqlConsole;
13205
+ const policy = console_.policy ?? {};
13206
+ const dialect = console_.dialect ?? "postgresql";
13207
+ const capabilities = policy.capabilities ?? [SqlCapability.READ];
13208
+ const maxRows = policy.maxRows ?? 200;
13209
+ router.get(
13210
+ `${prefix}/sql`,
13211
+ guarded(async (req, res) => {
13212
+ const state = await authenticate(req, res);
13213
+ if (state === null) return;
13214
+ html(
13215
+ res,
13216
+ renderSqlPage(context(req, state.session, state.visible), {
13217
+ sql: "",
13218
+ capabilities: [...capabilities],
13219
+ error: null,
13220
+ columns: [],
13221
+ rows: [],
13222
+ rowCount: null,
13223
+ truncated: false,
13224
+ durationMs: null
13225
+ })
13226
+ );
13227
+ })
13228
+ );
13229
+ router.post(
13230
+ `${prefix}/sql`,
13231
+ guarded(async (req, res) => {
13232
+ const state = await authenticate(req, res);
13233
+ if (state === null) return;
13234
+ if (!checkCsrf(req, res, state)) return;
13235
+ const body = req.body;
13236
+ const sql5 = typeof body.sql === "string" ? body.sql : "";
13237
+ const principal = backend.displayName(state.principal);
13238
+ const render = (view) => {
13239
+ html(
13240
+ res,
13241
+ renderSqlPage(context(req, state.session, state.visible), {
13242
+ sql: sql5,
13243
+ capabilities: [...capabilities],
13244
+ ...view
13245
+ }),
13246
+ view.error === null ? 200 : 400
13247
+ );
13248
+ };
13249
+ const parser = await loadSqlParser();
13250
+ const analysis = analyzeSql(sql5, dialect, parser);
13251
+ const verdict = checkSqlPolicy(analysis, policy);
13252
+ if (!verdict.allowed) {
13253
+ await audit(console_.onAudit, {
13254
+ sql: sql5,
13255
+ principal,
13256
+ allowed: false,
13257
+ reason: verdict.reason,
13258
+ analysis,
13259
+ durationMs: null,
13260
+ rowCount: null
13261
+ });
13262
+ render({
13263
+ error: verdict.reason,
13264
+ columns: [],
13265
+ rows: [],
13266
+ rowCount: null,
13267
+ truncated: false,
13268
+ durationMs: null
13269
+ });
13270
+ return;
13271
+ }
13272
+ const started = Date.now();
13273
+ let rows;
13274
+ try {
13275
+ rows = console_.run === void 0 ? await state.dbSession.raw(sql5).all() : await console_.run(sql5, state.dbSession);
13276
+ } catch (error) {
13277
+ const message = error instanceof Error ? error.message : String(error);
13278
+ await audit(console_.onAudit, {
13279
+ sql: sql5,
13280
+ principal,
13281
+ allowed: true,
13282
+ reason: message,
13283
+ analysis,
13284
+ durationMs: Date.now() - started,
13285
+ rowCount: null
13286
+ });
13287
+ render({
13288
+ error: message,
13289
+ columns: [],
13290
+ rows: [],
13291
+ rowCount: null,
13292
+ truncated: false,
13293
+ durationMs: null
13294
+ });
13295
+ return;
13296
+ }
13297
+ const durationMs = Date.now() - started;
13298
+ await audit(console_.onAudit, {
13299
+ sql: sql5,
13300
+ principal,
13301
+ allowed: true,
13302
+ reason: null,
13303
+ analysis,
13304
+ durationMs,
13305
+ rowCount: rows.length
13306
+ });
13307
+ const window = rows.slice(0, maxRows);
13308
+ const columns = window.length === 0 ? [] : Object.keys(window[0] ?? {});
13309
+ render({
13310
+ error: null,
13311
+ columns,
13312
+ rows: window.map(
13313
+ (row) => columns.map((column6) => formatCellValue(row[column6]))
13314
+ ),
13315
+ rowCount: rows.length,
13316
+ truncated: rows.length > window.length,
13317
+ durationMs
13318
+ });
13319
+ })
13320
+ );
13321
+ }
12688
13322
  router.get(
12689
13323
  `${prefix}/m/:slug`,
12690
13324
  guarded(async (req, res) => {
@@ -13040,6 +13674,8 @@ function makeAdminRouter(site, options) {
13040
13674
  title: admin.verboseName(),
13041
13675
  identity,
13042
13676
  audit: await buildAuditView(admin, row, state.dbSession),
13677
+ inlines: await buildInlines(admin, row, state, identity),
13678
+ inlineError: null,
13043
13679
  fields: admin.detailFieldNames().map((name) => ({ label: name, value: formatCellValue(row[name]) })),
13044
13680
  backUrl: `${prefix}/m/${admin.slug()}`,
13045
13681
  editUrl: await allows(state.principal, admin, AdminPermission.EDIT) ? `${prefix}/m/${admin.slug()}/${identity}/edit` : null,
@@ -13160,6 +13796,103 @@ function makeAdminRouter(site, options) {
13160
13796
  res.redirect(`${prefix}/m/${admin.slug()}/${identity}?ok=updated`);
13161
13797
  })
13162
13798
  );
13799
+ router.post(
13800
+ `${prefix}/m/:slug/:identity/inlines/:child`,
13801
+ guarded(async (req, res) => {
13802
+ const state = await authenticate(req, res);
13803
+ if (state === null) return;
13804
+ const admin = await resolveAdmin(req, res, state);
13805
+ if (admin === null) return;
13806
+ if (!checkCsrf(req, res, state)) return;
13807
+ const childSlug = String(req.params.child);
13808
+ const inline = admin.inlines.find(
13809
+ (entry) => entry.slug === childSlug && entry.editable
13810
+ );
13811
+ const childAdmin = inline === void 0 ? null : site.get(childSlug);
13812
+ if (inline === void 0 || childAdmin === null || !await allows(state.principal, childAdmin, AdminPermission.EDIT)) {
13813
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
13814
+ return;
13815
+ }
13816
+ const identity = String(req.params.identity);
13817
+ const parent = await findRow(admin, state.dbSession, identity);
13818
+ if (parent === null) {
13819
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
13820
+ return;
13821
+ }
13822
+ const parentId = parent[admin.identityField];
13823
+ const { rows: grouped, deletions } = groupInlineSubmission(
13824
+ req.body
13825
+ );
13826
+ const names = inlineFieldNames(childAdmin, inline);
13827
+ const childRepo = childAdmin.repository(state.dbSession);
13828
+ const actorId = backend.principalId(state.principal);
13829
+ const canDelete = inline.canDelete && await allows(state.principal, childAdmin, AdminPermission.DELETE);
13830
+ const failed = [];
13831
+ let formError = null;
13832
+ const owned = async (key) => await childRepo.first({
13833
+ [childAdmin.identityField]: key,
13834
+ [inline.fkField]: parentId
13835
+ });
13836
+ for (const [key, values] of Object.entries(grouped)) {
13837
+ const isNew = key.startsWith("new");
13838
+ if (!isNew && canDelete && deletions.has(key)) {
13839
+ if (await owned(key) !== null) {
13840
+ await childRepo.delete({ [childAdmin.identityField]: key });
13841
+ }
13842
+ continue;
13843
+ }
13844
+ if (isNew && Object.values(values).every((value) => value.trim() === "")) {
13845
+ continue;
13846
+ }
13847
+ const parsed = parseFormBody(childAdmin, values, { only: names });
13848
+ if (Object.keys(parsed.errors).length > 0) {
13849
+ failed.push({ key, values, errors: parsed.errors });
13850
+ continue;
13851
+ }
13852
+ try {
13853
+ if (isNew) {
13854
+ stampActor(childAdmin, parsed.data, actorId, true);
13855
+ await childRepo.create({
13856
+ ...parsed.data,
13857
+ [inline.fkField]: parentId
13858
+ });
13859
+ } else {
13860
+ if (await owned(key) === null) continue;
13861
+ stampActor(childAdmin, parsed.data, actorId, false);
13862
+ await childRepo.update(
13863
+ { [childAdmin.identityField]: key },
13864
+ parsed.data
13865
+ );
13866
+ }
13867
+ } catch (error) {
13868
+ formError = describeWriteFailure(childAdmin, error);
13869
+ failed.push({ key, values, errors: {} });
13870
+ }
13871
+ }
13872
+ if (failed.length > 0) {
13873
+ const fresh = await findRow(admin, state.dbSession, identity) ?? parent;
13874
+ html(
13875
+ res,
13876
+ renderDetailPage(context(req, state.session, state.visible), {
13877
+ title: admin.verboseName(),
13878
+ identity,
13879
+ audit: await buildAuditView(admin, fresh, state.dbSession),
13880
+ inlines: await buildInlines(admin, fresh, state, identity, {
13881
+ [childSlug]: failed
13882
+ }),
13883
+ inlineError: formError ?? "Some inline rows could not be saved.",
13884
+ fields: admin.detailFieldNames().map((name) => ({ label: name, value: formatCellValue(fresh[name]) })),
13885
+ backUrl: `${prefix}/m/${admin.slug()}`,
13886
+ editUrl: await allows(state.principal, admin, AdminPermission.EDIT) ? `${prefix}/m/${admin.slug()}/${identity}/edit` : null,
13887
+ deleteUrl: await allows(state.principal, admin, AdminPermission.DELETE) ? `${prefix}/m/${admin.slug()}/${identity}/delete` : null
13888
+ }),
13889
+ 400
13890
+ );
13891
+ return;
13892
+ }
13893
+ res.redirect(`${prefix}/m/${admin.slug()}/${identity}?ok=updated`);
13894
+ })
13895
+ );
13163
13896
  router.post(
13164
13897
  `${prefix}/m/:slug/:identity/delete`,
13165
13898
  guarded(async (req, res) => {
@@ -13181,6 +13914,89 @@ function makeAdminRouter(site, options) {
13181
13914
  const principal = await backend.loadPrincipal(dbSession, String(actor));
13182
13915
  return principal === null ? String(actor) : backend.displayName(principal);
13183
13916
  }
13917
+ async function buildInlines(admin, parent, state, identity, overrides = {}) {
13918
+ const parentId = parent[admin.identityField];
13919
+ const blocks = [];
13920
+ for (const inline of admin.inlines) {
13921
+ const childAdmin = site.get(inline.slug);
13922
+ const repository = childAdmin === null ? new BaseRepository(inline.model, state.dbSession) : childAdmin.repository(state.dbSession);
13923
+ const children = await repository.list({
13924
+ [inline.fkField]: parentId
13925
+ });
13926
+ const columns = inline.listDisplay ?? childAdmin?.listDisplayNames() ?? Object.keys(adminColumns(inline.model));
13927
+ const label = inline.label ?? childAdmin?.verboseNamePlural() ?? humanizeField(inline.slug);
13928
+ const visible = children.slice(0, INLINE_ROW_LIMIT);
13929
+ const editable = inline.editable && childAdmin !== null && await allows(state.principal, childAdmin, AdminPermission.EDIT);
13930
+ const addUrl = childAdmin !== null && await allows(state.principal, childAdmin, AdminPermission.CREATE) ? `${prefix}/m/${inline.slug}/new` : null;
13931
+ if (!editable || childAdmin === null) {
13932
+ blocks.push({
13933
+ label,
13934
+ total: children.length,
13935
+ columns,
13936
+ editable: false,
13937
+ canDelete: false,
13938
+ addUrl,
13939
+ formAction: "",
13940
+ rows: visible.map((child) => ({
13941
+ key: String(child[childAdmin?.identityField ?? "id"]),
13942
+ cells: columns.map((column6) => formatCellValue(child[column6])),
13943
+ fields: [],
13944
+ url: childAdmin === null ? null : `${prefix}/m/${inline.slug}/${String(child[childAdmin.identityField])}`
13945
+ })),
13946
+ newRow: null,
13947
+ truncated: children.length > visible.length
13948
+ });
13949
+ continue;
13950
+ }
13951
+ const names = inlineFieldNames(childAdmin, inline);
13952
+ const submitted = overrides[inline.slug];
13953
+ const rows = [];
13954
+ if (submitted !== void 0) {
13955
+ for (const entry of submitted) {
13956
+ rows.push({
13957
+ key: entry.key,
13958
+ cells: [],
13959
+ fields: inlineFields(
13960
+ childAdmin,
13961
+ names,
13962
+ entry.key,
13963
+ entry.values,
13964
+ entry.errors
13965
+ ),
13966
+ url: null
13967
+ });
13968
+ }
13969
+ } else {
13970
+ for (const child of visible) {
13971
+ const key = String(child[childAdmin.identityField]);
13972
+ rows.push({
13973
+ key,
13974
+ cells: [],
13975
+ fields: inlineFields(childAdmin, names, key, child, {}),
13976
+ url: `${prefix}/m/${inline.slug}/${key}`
13977
+ });
13978
+ }
13979
+ }
13980
+ blocks.push({
13981
+ label,
13982
+ total: children.length,
13983
+ columns: names.map(humanizeField),
13984
+ editable: true,
13985
+ canDelete: inline.canDelete && childAdmin.canDelete,
13986
+ addUrl,
13987
+ formAction: `${prefix}/m/${admin.slug()}/${identity}/inlines/${inline.slug}`,
13988
+ rows,
13989
+ newRow: {
13990
+ key: "new1",
13991
+ cells: [],
13992
+ fields: inlineFields(childAdmin, names, "new1", {}, {}),
13993
+ url: null
13994
+ },
13995
+ truncated: children.length > visible.length
13996
+ });
13997
+ }
13998
+ return blocks;
13999
+ }
13184
14000
  async function buildAuditView(admin, row, dbSession) {
13185
14001
  const fields = [];
13186
14002
  for (const name of admin.auditFieldNames()) {
@@ -13220,6 +14036,15 @@ function makeAdminRouter(site, options) {
13220
14036
  const row = await admin.repository(dbSession).first({ [admin.identityField]: identity });
13221
14037
  return row ?? null;
13222
14038
  }
14039
+ async function readLogs(req, dir) {
14040
+ const requested = queryString(req.query.source);
14041
+ const source = LOG_SOURCES.includes(requested) ? requested : "all";
14042
+ const search = queryString(req.query.q);
14043
+ const raw = await readLogEntries(dir, source);
14044
+ const entries = filterLogEntries(raw.map(toLogEntry), search);
14045
+ entries.reverse();
14046
+ return { entries, source, search };
14047
+ }
13223
14048
  function renderImport(req, state, admin, error, created, rowErrors) {
13224
14049
  return renderImportPage(context(req, state.session, state.visible), {
13225
14050
  title: admin.verboseNamePlural(),
@@ -13479,6 +14304,45 @@ function makeAdminRouter(site, options) {
13479
14304
  }
13480
14305
  return router;
13481
14306
  }
14307
+ function groupInlineSubmission(body) {
14308
+ const rows = {};
14309
+ const deletions = /* @__PURE__ */ new Set();
14310
+ for (const [key, raw] of Object.entries(body)) {
14311
+ if (!key.startsWith("row.")) continue;
14312
+ const parts = key.split(".");
14313
+ if (parts.length !== 3) continue;
14314
+ const [, rowKey, field] = parts;
14315
+ const value = typeof raw === "string" ? raw : "";
14316
+ if (field === "__delete") {
14317
+ if (!["", "false", "off", "0", "no"].includes(value.trim().toLowerCase())) {
14318
+ deletions.add(rowKey);
14319
+ }
14320
+ continue;
14321
+ }
14322
+ const row = rows[rowKey] ?? {};
14323
+ row[field] = value;
14324
+ rows[rowKey] = row;
14325
+ }
14326
+ return { rows, deletions };
14327
+ }
14328
+ function inlineFieldNames(childAdmin, inline) {
14329
+ return childAdmin.editableFieldNames().filter(
14330
+ (name) => name !== inline.fkField && !childAdmin.uploadFields.includes(name) && !childAdmin.autocompleteFields.includes(name)
14331
+ );
14332
+ }
14333
+ function inlineFields(childAdmin, names, key, values, errors) {
14334
+ return buildFormFields(childAdmin, { values, errors }).filter((field) => names.includes(field.name)).map((field) => ({ ...field, name: `row.${key}.${field.name}` }));
14335
+ }
14336
+ async function audit(hook, entry) {
14337
+ if (hook === void 0) return;
14338
+ try {
14339
+ await hook(entry);
14340
+ } catch (error) {
14341
+ logger2.error("Admin SQL audit hook failed", {
14342
+ error: error instanceof Error ? error.message : String(error)
14343
+ });
14344
+ }
14345
+ }
13482
14346
  function flagAllows(admin, action) {
13483
14347
  if (action === AdminPermission.CREATE) return admin.canCreate;
13484
14348
  if (action === AdminPermission.EDIT) return admin.canEdit;
@@ -15565,59 +16429,6 @@ function makeToolSpecRouter(spec, options = {}) {
15565
16429
  });
15566
16430
  return router;
15567
16431
  }
15568
- function filesFor(dir, source) {
15569
- if (source === "500") return [join(dir, HTTP_500_LOG_FILE)];
15570
- if (source === "all") return Object.values(LEVEL_LOG_FILES).map((f) => join(dir, f));
15571
- return [join(dir, LEVEL_LOG_FILES[source])];
15572
- }
15573
- async function readEntries(files) {
15574
- const entries = [];
15575
- for (const file of files) {
15576
- let content;
15577
- try {
15578
- content = await readFile(file, "utf8");
15579
- } catch {
15580
- continue;
15581
- }
15582
- for (const line of content.split("\n")) {
15583
- if (!line.trim()) continue;
15584
- try {
15585
- entries.push(JSON.parse(line));
15586
- } catch {
15587
- }
15588
- }
15589
- }
15590
- return entries;
15591
- }
15592
- function makeLogsRouter(options) {
15593
- const router = Router();
15594
- const path = options.path ?? "/logs";
15595
- const guards = options.guards ?? [];
15596
- const handler = (req, res, next) => {
15597
- const source = req.query.source ?? "all";
15598
- const validSources = ["all", "debug", "info", "warning", "error", "500"];
15599
- if (!validSources.includes(source)) {
15600
- res.status(422).json({ detail: "Invalid log source", code: "VALIDATION_ERROR", details: {} });
15601
- return;
15602
- }
15603
- const page2 = Math.max(1, Number(req.query.page) || 1);
15604
- const pageSize = Math.min(500, Math.max(1, Number(req.query.pageSize) || 50));
15605
- readEntries(filesFor(options.dir, source)).then((entries) => {
15606
- entries.reverse();
15607
- const total = entries.length;
15608
- const start = (page2 - 1) * pageSize;
15609
- res.json({
15610
- items: entries.slice(start, start + pageSize),
15611
- total,
15612
- page: page2,
15613
- pageSize,
15614
- pages: Math.ceil(total / pageSize)
15615
- });
15616
- }).catch(next);
15617
- };
15618
- router.get(path, ...guards, handler);
15619
- return router;
15620
- }
15621
16432
  function createTestDatabase(models) {
15622
16433
  const driver = NodeSqliteDriver.open(":memory:");
15623
16434
  for (const model of models) {
@@ -15649,6 +16460,6 @@ async function withTestDatabase(models, fn) {
15649
16460
  }
15650
16461
  }
15651
16462
 
15652
- export { ADMIN_CSS, ActivationService, AdminJsonSite, AdminModel, AdminPermission, AdminSessionStore, AdminSite, AppException, AttemptThrottle, AuditAction, BaseAuditLogModel, BaseController, BaseModel, BaseOAuthClient, BaseOutboxModel, BaseService, BaseUserModel, BaseUserRefreshTokenModel, BaseUserTokenModel, CEP_PATTERN, CNPJ_PATTERN, CPF_PATTERN, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, CircuitOpenError, CompositeFeatureFlagBackend, ConflictException, DEFAULT_DOCS_FAVICON, DEFAULT_LOCALE, EmailProvider, EmailUtils, EnvFeatureFlagBackend, EventStream, ExpiredTokenException, FeatureFlags, ForbiddenException, GitHubOAuthClient, GoogleOAuthClient, GracefulShutdown, HTTPClient, HTTP_500_LOG_FILE, HTTP_500_MARKER, HttpMetrics, IDEMPOTENCY_HEADER, InvalidTokenException, JSONLogger, JWTUtils, LEVEL_LOG_FILES, LocalUploadStorage, MemoryBroker, MemoryCacheManager, MemoryFeatureFlagBackend, MemoryIdempotencyStore, MemoryRateLimitStore, MemorySessionStore, MemoryThrottleBackend, MessageCatalog, MessagingHub, MetricsUtils, MfaService, MultipartLimitError, NotFoundException, OAuthError, OIDCProvider, OutboxRelay, OutboxStatus, PHONE_BR_PATTERN, PasswordResetService, REDOC_CDN_URL, REQUEST_ID_HEADER, RabbitBroker, RedisCacheManager, RedisIdempotencyStore, RedisRateLimitStore, RedisSSEBroker, RedisSessionStore, Region, RetryPolicy, S3UploadStorage, SSEBroker, ServerSentEvent, SessionService, TOTPHelper, TaskManager, TelegramProvider, TenantScopedRepository, TooManyRequestsException, TwilioSmsProvider, UF, UnauthorizedException, UserAuthService, UserModelAuthBackend, UserTokenPurpose, ValidationException, WebPushDispatcher, WebPushError, WebPushGoneError, WebSocketHub, WebhookSignatureVerifier, WhatsAppProvider, activationSchema, addLogSink, adminAction, adminColumns, adminLens, adminThemeCss, attachWebSocketHub, authResponseSchema, authSettingsShape, backupDatabase, bearerToken, bodySizeLimitMiddleware, broadcastText, buildContentDisposition, buildFormFields, buildPaginationLinkHeader, cached2 as cached, cepField, citiesByUf, cnpjField, coerceFlag, configureFileLogging, configureLogging, cpfField, cpfOrCnpjField, createApp, createOpenApiRegistry, createTestDatabase, createdByColumn, csrfMiddleware, csrfTokenMatches, cursorPaginationFilterSchema, cursorPaginationSchema, decodeCursor, defaultMessageCatalog, defineEnum, deletedAtColumn, diffSnapshots, emailSettingsShape, encodeCursor, envList, escapeHtml, filterForColumn, foreignKeyFields, foreignKeyLabel, foreignKeyTable, formatCellValue, formatFieldValue, generateCsrfToken, generateOAuthState, generateOpaqueToken, generateOpenApiDocument, getAuth, getClientIp, getConditions, getPaginationConditions, getRequestId, getState, hashOpaqueToken, humanizeField, idempotencyMiddleware, inboundMessageSchema, isColumnOptional, isMultipart, isSearchableColumn, isValidCep, isValidCity, isValidCnpj, isValidCpf, isValidCpfCnpj, isValidPhoneBr, isValidUf, jwtSettingsShape, keyByHeader, keyByIp, keyByJwtClaim, keyByJwtSubject, listStates, logEntrySchema, logSettingsShape, loginSchema, makeAdminJsonRouter, makeAdminRouter, makeAppExceptionHandler, makeAuthRouter, makeFlagGuard, makeHealthRouter, makeJwtAuthMiddleware, makeLogsRouter, makeMetricsRouter, makeSessionMiddleware, makeToolSpecRouter, makeTwilioWebhookRouter, makeUnhandledExceptionHandler, makeWhatsAppWebhookRouter, metricCard, mfaChallengeSchema, mfaCodeSchema, mfaEnrollResponseSchema, minioSettingsShape, modifyDict, mountOpenApiJson, mountRedoc, mountSwaggerUi, normalizeCep, normalizeCnpj, normalizeCpf, normalizeCpfCnpj, normalizePhoneBr, normalizeUf, notFoundHandler, onlyDigits, paginationFilterSchema, paginationSchema, parseAcceptLanguage, parseCookies, parseCsv, parseFormBody, parseMultipart, partitionTotal, passwordResetConfirmSchema, passwordResetRequestSchema, phoneBrField, prometheusMiddleware, rabbitmqSettingsShape, rateLimitMiddleware, redisSettingsShape, refreshSchema, registerExceptionHandlers, renderAuthResultPage, renderDashboardPage, renderDetailPage, renderFormPage, renderImportPage, renderLayout, renderListPage, renderLoginPage, renderMfaPage, renderPasswordResetFormPage, requestIdMiddleware, requestTracingMiddleware, requireRoles, resolveAdminTheme, resolveDownloadPath, resolveRedocBundle, runServer, runWithRequestContext, sendBytesDownload, sendFileDownload, sessionCookie, sessionSettingsShape, setRequestId, signupSchema, snapshot, sseResponse, statesByRegion, syncFilterSchema, syncPaginationSchema, tableNameFor, toUtc, tokenFromUrl, tokenPairSchema, tokenSettingsShape, trendDirection, trendPercent, ufField, updatedByColumn, uploadSettingsShape, userPublicSchema, utcnow, validateTwilioSignature, verifyOpaqueToken, webPushKeysSchema, webPushPayloadSchema, webPushSettingsShape, webPushSubscriptionSchema, webSocketSettingsShape, widgetForColumn, withTestDatabase, wrapWithSlowQueryLog, wsEnvelopeSchema };
16463
+ export { ADMIN_CSS, ActivationService, AdminJsonSite, AdminModel, AdminPermission, AdminSessionStore, AdminSite, AppException, AttemptThrottle, AuditAction, BaseAuditLogModel, BaseController, BaseModel, BaseOAuthClient, BaseOutboxModel, BaseService, BaseUserModel, BaseUserRefreshTokenModel, BaseUserTokenModel, CEP_PATTERN, CNPJ_PATTERN, CPF_PATTERN, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, CircuitOpenError, CompositeFeatureFlagBackend, ConflictException, DEFAULT_DOCS_FAVICON, DEFAULT_LOCALE, EmailProvider, EmailUtils, EnvFeatureFlagBackend, EventStream, ExpiredTokenException, FeatureFlags, ForbiddenException, GitHubOAuthClient, GoogleOAuthClient, GracefulShutdown, HTTPClient, HTTP_500_LOG_FILE, HTTP_500_MARKER, HttpMetrics, IDEMPOTENCY_HEADER, InvalidTokenException, JSONLogger, JWTUtils, LEVEL_LOG_FILES, LocalUploadStorage, MemoryBroker, MemoryCacheManager, MemoryFeatureFlagBackend, MemoryIdempotencyStore, MemoryRateLimitStore, MemorySessionStore, MemoryThrottleBackend, MessageCatalog, MessagingHub, MetricsUtils, MfaService, MultipartLimitError, NotFoundException, OAuthError, OIDCProvider, OutboxRelay, OutboxStatus, PHONE_BR_PATTERN, PasswordResetService, REDOC_CDN_URL, REQUEST_ID_HEADER, RabbitBroker, RedisCacheManager, RedisIdempotencyStore, RedisRateLimitStore, RedisSSEBroker, RedisSessionStore, Region, RetryPolicy, S3UploadStorage, SSEBroker, ServerSentEvent, SessionService, SqlCapability, TOTPHelper, TaskManager, TelegramProvider, TenantScopedRepository, TooManyRequestsException, TwilioSmsProvider, UF, UnauthorizedException, UserAuthService, UserModelAuthBackend, UserTokenPurpose, ValidationException, WebPushDispatcher, WebPushError, WebPushGoneError, WebSocketHub, WebhookSignatureVerifier, WhatsAppProvider, activationSchema, addLogSink, adminAction, adminColumns, adminInline, adminLens, adminThemeCss, analyzeSql, attachWebSocketHub, authResponseSchema, authSettingsShape, backupDatabase, bearerToken, bodySizeLimitMiddleware, broadcastText, buildContentDisposition, buildFormFields, buildPaginationLinkHeader, cached2 as cached, cepField, checkSqlPolicy, citiesByUf, cnpjField, coerceFlag, configureFileLogging, configureLogging, cpfField, cpfOrCnpjField, createApp, createOpenApiRegistry, createTestDatabase, createdByColumn, csrfMiddleware, csrfTokenMatches, cursorPaginationFilterSchema, cursorPaginationSchema, decodeCursor, defaultMessageCatalog, defineEnum, deletedAtColumn, diffSnapshots, emailSettingsShape, encodeCursor, envList, escapeHtml, filterForColumn, filterLogEntries, foreignKeyFields, foreignKeyLabel, foreignKeyTable, formatCellValue, formatFieldValue, generateCsrfToken, generateOAuthState, generateOpaqueToken, generateOpenApiDocument, getAuth, getClientIp, getConditions, getPaginationConditions, getRequestId, getState, groupInlineSubmission, hashOpaqueToken, humanizeField, idempotencyMiddleware, inboundMessageSchema, isColumnOptional, isMultipart, isSearchableColumn, isValidCep, isValidCity, isValidCnpj, isValidCpf, isValidCpfCnpj, isValidPhoneBr, isValidUf, jwtSettingsShape, keyByHeader, keyByIp, keyByJwtClaim, keyByJwtSubject, listStates, loadSqlParser, logEntrySchema, logSettingsShape, loginSchema, makeAdminJsonRouter, makeAdminRouter, makeAppExceptionHandler, makeAuthRouter, makeFlagGuard, makeHealthRouter, makeJwtAuthMiddleware, makeLogsRouter, makeMetricsRouter, makeSessionMiddleware, makeToolSpecRouter, makeTwilioWebhookRouter, makeUnhandledExceptionHandler, makeWhatsAppWebhookRouter, metricCard, mfaChallengeSchema, mfaCodeSchema, mfaEnrollResponseSchema, minioSettingsShape, modifyDict, mountOpenApiJson, mountRedoc, mountSwaggerUi, normalizeCep, normalizeCnpj, normalizeCpf, normalizeCpfCnpj, normalizePhoneBr, normalizeUf, notFoundHandler, onlyDigits, paginationFilterSchema, paginationSchema, parseAcceptLanguage, parseCookies, parseCsv, parseFormBody, parseMultipart, partitionTotal, passwordResetConfirmSchema, passwordResetRequestSchema, phoneBrField, prometheusMiddleware, rabbitmqSettingsShape, rateLimitMiddleware, readLogEntries, redisSettingsShape, refreshSchema, registerExceptionHandlers, renderAuthResultPage, renderDashboardPage, renderDetailPage, renderFormPage, renderImportPage, renderLayout, renderListPage, renderLogEntriesJson, renderLogEntriesMarkdown, renderLoginPage, renderLogsPage, renderMfaPage, renderPasswordResetFormPage, renderSqlPage, requestIdMiddleware, requestTracingMiddleware, requireRoles, resolveAdminTheme, resolveDownloadPath, resolveRedocBundle, runServer, runWithRequestContext, sendBytesDownload, sendFileDownload, sessionCookie, sessionSettingsShape, setRequestId, signupSchema, snapshot, sseResponse, statesByRegion, syncFilterSchema, syncPaginationSchema, tableNameFor, toLogEntry, toUtc, tokenFromUrl, tokenPairSchema, tokenSettingsShape, trendDirection, trendPercent, ufField, updatedByColumn, uploadSettingsShape, userPublicSchema, utcnow, validateTwilioSignature, verifyOpaqueToken, webPushKeysSchema, webPushPayloadSchema, webPushSettingsShape, webPushSubscriptionSchema, webSocketSettingsShape, widgetForColumn, withTestDatabase, wrapWithSlowQueryLog, wsEnvelopeSchema };
15653
16464
  //# sourceMappingURL=index.js.map
15654
16465
  //# sourceMappingURL=index.js.map