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.js CHANGED
@@ -1,5 +1,5 @@
1
- import { z, looseBoolean, toDict, PasswordUtils } from './chunk-3NS5KVHT.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-3NS5KVHT.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,199 @@ 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
+
9337
9530
  // src/admin/inlines.ts
9338
9531
  function adminInline(options) {
9339
9532
  const slug = options.model.tablename;
@@ -11985,7 +12178,7 @@ function escapeHtml(value) {
11985
12178
  return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
11986
12179
  }
11987
12180
  function renderLayout(context, title, body) {
11988
- const { site, theme, prefix, session, currentPath, navModels, messages } = context;
12181
+ const { site, theme, prefix, session, currentPath, navModels, navSystem, messages } = context;
11989
12182
  const authed = session !== null;
11990
12183
  const indexUrl = `${prefix}/`;
11991
12184
  const navLink = (url, label, active) => `<a href="${escapeHtml(url)}" class="tempest-admin-sidebar__link${active ? " tempest-admin-sidebar__link--active" : ""}">${escapeHtml(label)}</a>`;
@@ -11995,6 +12188,9 @@ function renderLayout(context, title, body) {
11995
12188
  ${navLink(indexUrl, "Dashboard", currentPath === indexUrl)}
11996
12189
  ${navModels.length > 0 ? `<span class="tempest-admin-sidebar__heading">Models</span>${navModels.map(
11997
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))
11998
12194
  ).join("")}` : ""}
11999
12195
  </nav>
12000
12196
  </aside>` : "";
@@ -12345,11 +12541,11 @@ function renderInline(inline, csrfToken, error) {
12345
12541
  ${more}
12346
12542
  </section>`;
12347
12543
  }
12348
- function renderAuditPanel(audit) {
12349
- const rows = audit.fields.map(
12544
+ function renderAuditPanel(audit2) {
12545
+ const rows = audit2.fields.map(
12350
12546
  (field) => `<dt>${escapeHtml(field.label)}</dt><dd>${field.value === "" ? "<em>\u2014</em>" : escapeHtml(field.value)}</dd>`
12351
12547
  ).join("");
12352
- 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) => {
12353
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(
12354
12550
  (change) => `<tr><td>${escapeHtml(change.field)}</td><td>${escapeHtml(change.before)}</td><td>${escapeHtml(change.after)}</td></tr>`
12355
12551
  ).join("")}</tbody></table>` : "<p><em>No field changes recorded.</em></p>";
@@ -12447,6 +12643,86 @@ function renderFormPage(context, view) {
12447
12643
  </section>`;
12448
12644
  return renderLayout(context, `${heading} \xB7 ${context.site.title}`, body);
12449
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
+ }
12450
12726
  function renderImportPage(context, view) {
12451
12727
  if (context.session === null) throw new Error("The import page requires a session");
12452
12728
  const summary = view.created === null ? "" : `<p class="tempest-admin-import__summary">Created ${escapeHtml(view.created)} record${view.created === 1 ? "" : "s"}.</p>`;
@@ -12532,7 +12808,66 @@ var AUTOCOMPLETE_SCRIPT = `<script>
12532
12808
  });
12533
12809
  })();
12534
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
+ }
12535
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;
12536
12871
  var INLINE_ROW_LIMIT = 50;
12537
12872
  var AUTOCOMPLETE_LIMIT = 20;
12538
12873
  var AUDIT_HISTORY_LIMIT = 50;
@@ -12564,6 +12899,13 @@ function makeAdminRouter(site, options) {
12564
12899
  const theme = resolveAdminTheme(site.theme);
12565
12900
  const showMetrics = options.showMetrics ?? true;
12566
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
+ }
12567
12909
  const maxUploadBytes = options.maxUploadBytes ?? 10 * 1024 * 1024;
12568
12910
  const sessions = new AdminSessionStore({
12569
12911
  secret: options.secretKey,
@@ -12585,6 +12927,7 @@ function makeAdminRouter(site, options) {
12585
12927
  label: admin.verboseNamePlural(),
12586
12928
  url: `${prefix}/m/${admin.slug()}`
12587
12929
  })),
12930
+ navSystem: systemNav,
12588
12931
  messages: flashFor(req)
12589
12932
  });
12590
12933
  const allows = async (principal, admin, action) => {
@@ -12795,6 +13138,187 @@ function makeAdminRouter(site, options) {
12795
13138
  );
12796
13139
  })
12797
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
+ }
12798
13322
  router.get(
12799
13323
  `${prefix}/m/:slug`,
12800
13324
  guarded(async (req, res) => {
@@ -13512,6 +14036,15 @@ function makeAdminRouter(site, options) {
13512
14036
  const row = await admin.repository(dbSession).first({ [admin.identityField]: identity });
13513
14037
  return row ?? null;
13514
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
+ }
13515
14048
  function renderImport(req, state, admin, error, created, rowErrors) {
13516
14049
  return renderImportPage(context(req, state.session, state.visible), {
13517
14050
  title: admin.verboseNamePlural(),
@@ -13800,6 +14333,16 @@ function inlineFieldNames(childAdmin, inline) {
13800
14333
  function inlineFields(childAdmin, names, key, values, errors) {
13801
14334
  return buildFormFields(childAdmin, { values, errors }).filter((field) => names.includes(field.name)).map((field) => ({ ...field, name: `row.${key}.${field.name}` }));
13802
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
+ }
13803
14346
  function flagAllows(admin, action) {
13804
14347
  if (action === AdminPermission.CREATE) return admin.canCreate;
13805
14348
  if (action === AdminPermission.EDIT) return admin.canEdit;
@@ -15886,59 +16429,6 @@ function makeToolSpecRouter(spec, options = {}) {
15886
16429
  });
15887
16430
  return router;
15888
16431
  }
15889
- function filesFor(dir, source) {
15890
- if (source === "500") return [join(dir, HTTP_500_LOG_FILE)];
15891
- if (source === "all") return Object.values(LEVEL_LOG_FILES).map((f) => join(dir, f));
15892
- return [join(dir, LEVEL_LOG_FILES[source])];
15893
- }
15894
- async function readEntries(files) {
15895
- const entries = [];
15896
- for (const file of files) {
15897
- let content;
15898
- try {
15899
- content = await readFile(file, "utf8");
15900
- } catch {
15901
- continue;
15902
- }
15903
- for (const line of content.split("\n")) {
15904
- if (!line.trim()) continue;
15905
- try {
15906
- entries.push(JSON.parse(line));
15907
- } catch {
15908
- }
15909
- }
15910
- }
15911
- return entries;
15912
- }
15913
- function makeLogsRouter(options) {
15914
- const router = Router();
15915
- const path = options.path ?? "/logs";
15916
- const guards = options.guards ?? [];
15917
- const handler = (req, res, next) => {
15918
- const source = req.query.source ?? "all";
15919
- const validSources = ["all", "debug", "info", "warning", "error", "500"];
15920
- if (!validSources.includes(source)) {
15921
- res.status(422).json({ detail: "Invalid log source", code: "VALIDATION_ERROR", details: {} });
15922
- return;
15923
- }
15924
- const page2 = Math.max(1, Number(req.query.page) || 1);
15925
- const pageSize = Math.min(500, Math.max(1, Number(req.query.pageSize) || 50));
15926
- readEntries(filesFor(options.dir, source)).then((entries) => {
15927
- entries.reverse();
15928
- const total = entries.length;
15929
- const start = (page2 - 1) * pageSize;
15930
- res.json({
15931
- items: entries.slice(start, start + pageSize),
15932
- total,
15933
- page: page2,
15934
- pageSize,
15935
- pages: Math.ceil(total / pageSize)
15936
- });
15937
- }).catch(next);
15938
- };
15939
- router.get(path, ...guards, handler);
15940
- return router;
15941
- }
15942
16432
  function createTestDatabase(models) {
15943
16433
  const driver = NodeSqliteDriver.open(":memory:");
15944
16434
  for (const model of models) {
@@ -15970,6 +16460,6 @@ async function withTestDatabase(models, fn) {
15970
16460
  }
15971
16461
  }
15972
16462
 
15973
- 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, adminInline, 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, groupInlineSubmission, 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 };
15974
16464
  //# sourceMappingURL=index.js.map
15975
16465
  //# sourceMappingURL=index.js.map