tempest-express-sdk 0.28.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,199 @@ 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
+
9464
9657
  // src/admin/inlines.ts
9465
9658
  function adminInline(options) {
9466
9659
  const slug = options.model.tablename;
@@ -12112,7 +12305,7 @@ function escapeHtml(value) {
12112
12305
  return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
12113
12306
  }
12114
12307
  function renderLayout(context, title, body) {
12115
- const { site, theme, prefix, session, currentPath, navModels, messages } = context;
12308
+ const { site, theme, prefix, session, currentPath, navModels, navSystem, messages } = context;
12116
12309
  const authed = session !== null;
12117
12310
  const indexUrl = `${prefix}/`;
12118
12311
  const navLink = (url, label, active) => `<a href="${escapeHtml(url)}" class="tempest-admin-sidebar__link${active ? " tempest-admin-sidebar__link--active" : ""}">${escapeHtml(label)}</a>`;
@@ -12122,6 +12315,9 @@ function renderLayout(context, title, body) {
12122
12315
  ${navLink(indexUrl, "Dashboard", currentPath === indexUrl)}
12123
12316
  ${navModels.length > 0 ? `<span class="tempest-admin-sidebar__heading">Models</span>${navModels.map(
12124
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))
12125
12321
  ).join("")}` : ""}
12126
12322
  </nav>
12127
12323
  </aside>` : "";
@@ -12472,11 +12668,11 @@ function renderInline(inline, csrfToken, error) {
12472
12668
  ${more}
12473
12669
  </section>`;
12474
12670
  }
12475
- function renderAuditPanel(audit) {
12476
- const rows = audit.fields.map(
12671
+ function renderAuditPanel(audit2) {
12672
+ const rows = audit2.fields.map(
12477
12673
  (field) => `<dt>${escapeHtml(field.label)}</dt><dd>${field.value === "" ? "<em>\u2014</em>" : escapeHtml(field.value)}</dd>`
12478
12674
  ).join("");
12479
- 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) => {
12480
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(
12481
12677
  (change) => `<tr><td>${escapeHtml(change.field)}</td><td>${escapeHtml(change.before)}</td><td>${escapeHtml(change.after)}</td></tr>`
12482
12678
  ).join("")}</tbody></table>` : "<p><em>No field changes recorded.</em></p>";
@@ -12574,6 +12770,86 @@ function renderFormPage(context, view) {
12574
12770
  </section>`;
12575
12771
  return renderLayout(context, `${heading} \xB7 ${context.site.title}`, body);
12576
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
+ }
12577
12853
  function renderImportPage(context, view) {
12578
12854
  if (context.session === null) throw new Error("The import page requires a session");
12579
12855
  const summary = view.created === null ? "" : `<p class="tempest-admin-import__summary">Created ${escapeHtml(view.created)} record${view.created === 1 ? "" : "s"}.</p>`;
@@ -12659,7 +12935,66 @@ var AUTOCOMPLETE_SCRIPT = `<script>
12659
12935
  });
12660
12936
  })();
12661
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
+ }
12662
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;
12663
12998
  var INLINE_ROW_LIMIT = 50;
12664
12999
  var AUTOCOMPLETE_LIMIT = 20;
12665
13000
  var AUDIT_HISTORY_LIMIT = 50;
@@ -12691,6 +13026,13 @@ function makeAdminRouter(site, options) {
12691
13026
  const theme = resolveAdminTheme(site.theme);
12692
13027
  const showMetrics = options.showMetrics ?? true;
12693
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
+ }
12694
13036
  const maxUploadBytes = options.maxUploadBytes ?? 10 * 1024 * 1024;
12695
13037
  const sessions = new AdminSessionStore({
12696
13038
  secret: options.secretKey,
@@ -12712,6 +13054,7 @@ function makeAdminRouter(site, options) {
12712
13054
  label: admin.verboseNamePlural(),
12713
13055
  url: `${prefix}/m/${admin.slug()}`
12714
13056
  })),
13057
+ navSystem: systemNav,
12715
13058
  messages: flashFor(req)
12716
13059
  });
12717
13060
  const allows = async (principal, admin, action) => {
@@ -12922,6 +13265,187 @@ function makeAdminRouter(site, options) {
12922
13265
  );
12923
13266
  })
12924
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
+ }
12925
13449
  router.get(
12926
13450
  `${prefix}/m/:slug`,
12927
13451
  guarded(async (req, res) => {
@@ -13639,6 +14163,15 @@ function makeAdminRouter(site, options) {
13639
14163
  const row = await admin.repository(dbSession).first({ [admin.identityField]: identity });
13640
14164
  return row ?? null;
13641
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
+ }
13642
14175
  function renderImport(req, state, admin, error, created, rowErrors) {
13643
14176
  return renderImportPage(context(req, state.session, state.visible), {
13644
14177
  title: admin.verboseNamePlural(),
@@ -13927,6 +14460,16 @@ function inlineFieldNames(childAdmin, inline) {
13927
14460
  function inlineFields(childAdmin, names, key, values, errors) {
13928
14461
  return buildFormFields(childAdmin, { values, errors }).filter((field) => names.includes(field.name)).map((field) => ({ ...field, name: `row.${key}.${field.name}` }));
13929
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
+ }
13930
14473
  function flagAllows(admin, action) {
13931
14474
  if (action === AdminPermission.CREATE) return admin.canCreate;
13932
14475
  if (action === AdminPermission.EDIT) return admin.canEdit;
@@ -16013,59 +16556,6 @@ function makeToolSpecRouter(spec, options = {}) {
16013
16556
  });
16014
16557
  return router;
16015
16558
  }
16016
- function filesFor(dir, source) {
16017
- if (source === "500") return [path.join(dir, HTTP_500_LOG_FILE)];
16018
- if (source === "all") return Object.values(LEVEL_LOG_FILES).map((f) => path.join(dir, f));
16019
- return [path.join(dir, LEVEL_LOG_FILES[source])];
16020
- }
16021
- async function readEntries(files) {
16022
- const entries = [];
16023
- for (const file of files) {
16024
- let content;
16025
- try {
16026
- content = await promises.readFile(file, "utf8");
16027
- } catch {
16028
- continue;
16029
- }
16030
- for (const line of content.split("\n")) {
16031
- if (!line.trim()) continue;
16032
- try {
16033
- entries.push(JSON.parse(line));
16034
- } catch {
16035
- }
16036
- }
16037
- }
16038
- return entries;
16039
- }
16040
- function makeLogsRouter(options) {
16041
- const router = express3.Router();
16042
- const path = options.path ?? "/logs";
16043
- const guards = options.guards ?? [];
16044
- const handler = (req, res, next) => {
16045
- const source = req.query.source ?? "all";
16046
- const validSources = ["all", "debug", "info", "warning", "error", "500"];
16047
- if (!validSources.includes(source)) {
16048
- res.status(422).json({ detail: "Invalid log source", code: "VALIDATION_ERROR", details: {} });
16049
- return;
16050
- }
16051
- const page2 = Math.max(1, Number(req.query.page) || 1);
16052
- const pageSize = Math.min(500, Math.max(1, Number(req.query.pageSize) || 50));
16053
- readEntries(filesFor(options.dir, source)).then((entries) => {
16054
- entries.reverse();
16055
- const total = entries.length;
16056
- const start = (page2 - 1) * pageSize;
16057
- res.json({
16058
- items: entries.slice(start, start + pageSize),
16059
- total,
16060
- page: page2,
16061
- pageSize,
16062
- pages: Math.ceil(total / pageSize)
16063
- });
16064
- }).catch(next);
16065
- };
16066
- router.get(path, ...guards, handler);
16067
- return router;
16068
- }
16069
16559
  function createTestDatabase(models) {
16070
16560
  const driver = tempestDbJs.NodeSqliteDriver.open(":memory:");
16071
16561
  for (const model of models) {
@@ -16098,7 +16588,7 @@ async function withTestDatabase(models, fn) {
16098
16588
  }
16099
16589
 
16100
16590
  // src/version.ts
16101
- var VERSION = "0.28.0";
16591
+ var VERSION = "0.29.0";
16102
16592
 
16103
16593
  Object.defineProperty(exports, "OpenAPIRegistry", {
16104
16594
  enumerable: true,
@@ -16335,6 +16825,7 @@ exports.S3UploadStorage = S3UploadStorage;
16335
16825
  exports.SSEBroker = SSEBroker;
16336
16826
  exports.ServerSentEvent = ServerSentEvent;
16337
16827
  exports.SessionService = SessionService;
16828
+ exports.SqlCapability = SqlCapability;
16338
16829
  exports.TOTPHelper = TOTPHelper;
16339
16830
  exports.TaskManager = TaskManager;
16340
16831
  exports.TelegramProvider = TelegramProvider;
@@ -16361,6 +16852,7 @@ exports.adminColumns = adminColumns;
16361
16852
  exports.adminInline = adminInline;
16362
16853
  exports.adminLens = adminLens;
16363
16854
  exports.adminThemeCss = adminThemeCss;
16855
+ exports.analyzeSql = analyzeSql;
16364
16856
  exports.attachWebSocketHub = attachWebSocketHub;
16365
16857
  exports.authResponseSchema = authResponseSchema;
16366
16858
  exports.authSettingsShape = authSettingsShape;
@@ -16377,6 +16869,7 @@ exports.buildPaginationLinkHeader = buildPaginationLinkHeader;
16377
16869
  exports.cached = cached3;
16378
16870
  exports.centsField = centsField;
16379
16871
  exports.cepField = cepField;
16872
+ exports.checkSqlPolicy = checkSqlPolicy;
16380
16873
  exports.citiesByUf = citiesByUf;
16381
16874
  exports.cnpjField = cnpjField;
16382
16875
  exports.coerceFlag = coerceFlag;
@@ -16405,6 +16898,7 @@ exports.envBoolean = looseBoolean;
16405
16898
  exports.envList = envList;
16406
16899
  exports.escapeHtml = escapeHtml;
16407
16900
  exports.filterForColumn = filterForColumn;
16901
+ exports.filterLogEntries = filterLogEntries;
16408
16902
  exports.foreignKeyFields = foreignKeyFields;
16409
16903
  exports.foreignKeyLabel = foreignKeyLabel;
16410
16904
  exports.foreignKeyTable = foreignKeyTable;
@@ -16444,6 +16938,7 @@ exports.keyByJwtSubject = keyByJwtSubject;
16444
16938
  exports.latitudeField = latitudeField;
16445
16939
  exports.listStates = listStates;
16446
16940
  exports.loadSettings = loadSettings;
16941
+ exports.loadSqlParser = loadSqlParser;
16447
16942
  exports.logEntrySchema = logEntrySchema;
16448
16943
  exports.logSettingsShape = logSettingsShape;
16449
16944
  exports.loginSchema = loginSchema;
@@ -16504,6 +16999,7 @@ exports.rabbitmqSettingsShape = rabbitmqSettingsShape;
16504
16999
  exports.rateLimitMiddleware = rateLimitMiddleware;
16505
17000
  exports.ratingField = ratingField;
16506
17001
  exports.ratioField = ratioField;
17002
+ exports.readLogEntries = readLogEntries;
16507
17003
  exports.redisSettingsShape = redisSettingsShape;
16508
17004
  exports.refreshSchema = refreshSchema;
16509
17005
  exports.registerExceptionHandlers = registerExceptionHandlers;
@@ -16514,9 +17010,13 @@ exports.renderFormPage = renderFormPage;
16514
17010
  exports.renderImportPage = renderImportPage;
16515
17011
  exports.renderLayout = renderLayout;
16516
17012
  exports.renderListPage = renderListPage;
17013
+ exports.renderLogEntriesJson = renderLogEntriesJson;
17014
+ exports.renderLogEntriesMarkdown = renderLogEntriesMarkdown;
16517
17015
  exports.renderLoginPage = renderLoginPage;
17016
+ exports.renderLogsPage = renderLogsPage;
16518
17017
  exports.renderMfaPage = renderMfaPage;
16519
17018
  exports.renderPasswordResetFormPage = renderPasswordResetFormPage;
17019
+ exports.renderSqlPage = renderSqlPage;
16520
17020
  exports.requestIdMiddleware = requestIdMiddleware;
16521
17021
  exports.requestTracingMiddleware = requestTracingMiddleware;
16522
17022
  exports.requireRoles = requireRoles;
@@ -16540,6 +17040,7 @@ exports.syncFilterSchema = syncFilterSchema;
16540
17040
  exports.syncPaginationSchema = syncPaginationSchema;
16541
17041
  exports.tableNameFor = tableNameFor;
16542
17042
  exports.toDict = toDict;
17043
+ exports.toLogEntry = toLogEntry;
16543
17044
  exports.toUtc = toUtc;
16544
17045
  exports.tokenFromUrl = tokenFromUrl;
16545
17046
  exports.tokenPairSchema = tokenPairSchema;