tempest-express-sdk 0.23.0 → 0.25.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
@@ -11,14 +11,14 @@ var fs = require('fs');
11
11
  var path = require('path');
12
12
  var os = require('os');
13
13
  var util = require('util');
14
- var express2 = require('express');
14
+ var express3 = require('express');
15
15
  var module$1 = require('module');
16
16
  var swaggerUiDist = require('swagger-ui-dist');
17
17
  var migrations = require('tempest-db-js/migrations');
18
18
 
19
19
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
20
20
 
21
- var express2__default = /*#__PURE__*/_interopDefault(express2);
21
+ var express3__default = /*#__PURE__*/_interopDefault(express3);
22
22
 
23
23
  // src/core/context.ts
24
24
  var storage = new async_hooks.AsyncLocalStorage();
@@ -893,7 +893,7 @@ function wrapWithSlowQueryLog(driver, options = {}) {
893
893
  const thresholdMs = options.thresholdMs ?? 500;
894
894
  const level = options.level ?? "warning";
895
895
  const logParameters = options.logParameters ?? false;
896
- const logger4 = new JSONLogger(
896
+ const logger5 = new JSONLogger(
897
897
  options.loggerName ?? "tempest_express_sdk.db.slow_query"
898
898
  );
899
899
  const timed = (exec) => {
@@ -904,7 +904,7 @@ function wrapWithSlowQueryLog(driver, options = {}) {
904
904
  } finally {
905
905
  const durationMs = Number(process.hrtime.bigint() - start) / 1e6;
906
906
  if (durationMs >= thresholdMs) {
907
- logger4.log(level, "slow query", {
907
+ logger5.log(level, "slow query", {
908
908
  sql: sql5,
909
909
  durationMs: Math.round(durationMs * 1e3) / 1e3,
910
910
  ...logParameters ? { params } : {}
@@ -9118,7 +9118,7 @@ function safeEqual(a, b) {
9118
9118
  }
9119
9119
  function makeWhatsAppWebhookRouter(options) {
9120
9120
  const path = options.path ?? "/whatsapp/inbound";
9121
- const router = express2.Router();
9121
+ const router = express3.Router();
9122
9122
  router.post(path, async (req, res) => {
9123
9123
  if (options.apiKey) {
9124
9124
  const provided = req.header("x-api-key") ?? "";
@@ -9294,7 +9294,7 @@ function validateTwilioSignature(authToken, url, params, signature) {
9294
9294
  }
9295
9295
  function makeTwilioWebhookRouter(options) {
9296
9296
  const path = options.path ?? "/sms/inbound";
9297
- const router = express2.Router();
9297
+ const router = express3.Router();
9298
9298
  router.post(path, async (req, res) => {
9299
9299
  const body = req.body ?? {};
9300
9300
  if (options.authToken) {
@@ -9444,8 +9444,3508 @@ var MessagingHub = class {
9444
9444
  }
9445
9445
  };
9446
9446
 
9447
+ // src/admin/actions.ts
9448
+ function slugify(label) {
9449
+ return label.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "action";
9450
+ }
9451
+ function adminAction(options, handler) {
9452
+ const name = options.name ?? slugify(options.label);
9453
+ if (name === "") throw new Error("adminAction requires a non-empty name or label");
9454
+ return {
9455
+ name,
9456
+ label: options.label,
9457
+ handler,
9458
+ dangerous: options.dangerous ?? false
9459
+ };
9460
+ }
9461
+
9462
+ // src/admin/columns.ts
9463
+ function humanizeField(name) {
9464
+ return name.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").trim().split(/\s+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
9465
+ }
9466
+ function adminColumns(model) {
9467
+ return tempestDbJs.columnsOf(model);
9468
+ }
9469
+ function widgetForColumn(column6) {
9470
+ const { kind, meta } = column6.type;
9471
+ const plain = (widget) => ({
9472
+ widget,
9473
+ step: null,
9474
+ options: []
9475
+ });
9476
+ switch (kind) {
9477
+ case "json":
9478
+ return plain("json");
9479
+ case "boolean":
9480
+ return plain("checkbox");
9481
+ case "enum":
9482
+ return {
9483
+ widget: "select",
9484
+ step: null,
9485
+ options: (meta.values ?? []).map((value) => ({
9486
+ value,
9487
+ label: humanizeField(value)
9488
+ }))
9489
+ };
9490
+ case "smallint":
9491
+ case "integer":
9492
+ case "bigint":
9493
+ return { widget: "number", step: "1", options: [] };
9494
+ case "numeric":
9495
+ case "real":
9496
+ case "double":
9497
+ return { widget: "number", step: "any", options: [] };
9498
+ case "datetime":
9499
+ case "timestamp":
9500
+ return plain("datetime");
9501
+ case "date":
9502
+ return plain("date");
9503
+ case "time":
9504
+ return plain("time");
9505
+ case "text":
9506
+ return plain("textarea");
9507
+ case "varchar":
9508
+ case "char":
9509
+ return plain(meta.length !== void 0 && meta.length > 255 ? "textarea" : "text");
9510
+ default:
9511
+ return plain("text");
9512
+ }
9513
+ }
9514
+ function isColumnOptional(column6) {
9515
+ return !column6.flags.notNull || column6.flags.hasDefault || column6.flags.primaryKey;
9516
+ }
9517
+ function filterForColumn(column6) {
9518
+ const { kind, meta } = column6.type;
9519
+ if (kind === "boolean") {
9520
+ return {
9521
+ kind: "select",
9522
+ options: [
9523
+ { value: "true", label: "Yes" },
9524
+ { value: "false", label: "No" }
9525
+ ]
9526
+ };
9527
+ }
9528
+ if (kind === "enum") {
9529
+ return {
9530
+ kind: "select",
9531
+ options: (meta.values ?? []).map((value) => ({
9532
+ value,
9533
+ label: humanizeField(value)
9534
+ }))
9535
+ };
9536
+ }
9537
+ if (kind === "date" || kind === "datetime" || kind === "timestamp") {
9538
+ return { kind: "daterange", options: [] };
9539
+ }
9540
+ return { kind: "text", options: [] };
9541
+ }
9542
+ function foreignKeyTable(column6) {
9543
+ return column6.reference?.table ?? null;
9544
+ }
9545
+ function isSearchableColumn(column6) {
9546
+ const { kind } = column6.type;
9547
+ return kind === "varchar" || kind === "text" || kind === "char";
9548
+ }
9549
+
9550
+ // src/admin/config.ts
9551
+ var NEVER_EDITABLE = [
9552
+ "id",
9553
+ "createdAt",
9554
+ "updatedAt",
9555
+ "hashedPassword"
9556
+ ];
9557
+ var NEVER_LISTED = ["hashedPassword"];
9558
+ var AdminModel = class {
9559
+ /** The managed model class. */
9560
+ model;
9561
+ /** Columns surfaced as filter controls. */
9562
+ listFilter;
9563
+ /** Text columns the search box matches against. */
9564
+ searchFields;
9565
+ /** Columns locked in the create/edit form. */
9566
+ readonlyFields;
9567
+ /** Default ordering column, or `null` to leave it to the repository. */
9568
+ orderKey;
9569
+ /** Whether {@link AdminModel.orderKey} sorts ascending. */
9570
+ orderAscending;
9571
+ /** Rows per page in the list view. */
9572
+ pageSize;
9573
+ /** Column used to look a single row up from the detail URL. */
9574
+ identityField;
9575
+ /** Whether the create form is exposed. */
9576
+ canCreate;
9577
+ /** Whether the edit form is exposed. */
9578
+ canEdit;
9579
+ /** Whether the delete action is exposed. */
9580
+ canDelete;
9581
+ actions = /* @__PURE__ */ new Map();
9582
+ slugOverride;
9583
+ listDisplayOverride;
9584
+ verboseNameOverride;
9585
+ verboseNamePluralOverride;
9586
+ /**
9587
+ * Build and validate the configuration.
9588
+ *
9589
+ * @param options - The declarative configuration. See {@link AdminModelOptions}.
9590
+ * @throws Error When a referenced column does not exist on the model.
9591
+ */
9592
+ constructor(options) {
9593
+ this.model = options.model;
9594
+ this.slugOverride = options.slug ?? null;
9595
+ this.listDisplayOverride = options.listDisplay === void 0 ? null : [...options.listDisplay];
9596
+ this.listFilter = [...options.listFilter ?? []];
9597
+ this.searchFields = [...options.searchFields ?? []];
9598
+ this.readonlyFields = [...options.readonlyFields ?? []];
9599
+ this.pageSize = options.pageSize ?? 25;
9600
+ this.identityField = options.identityField ?? "id";
9601
+ this.verboseNameOverride = options.verboseName ?? null;
9602
+ this.verboseNamePluralOverride = options.verboseNamePlural ?? null;
9603
+ this.canCreate = options.canCreate ?? true;
9604
+ this.canEdit = options.canEdit ?? true;
9605
+ this.canDelete = options.canDelete ?? true;
9606
+ for (const action of options.actions ?? []) {
9607
+ if (this.actions.has(action.name)) {
9608
+ throw new Error(
9609
+ `Duplicate admin action name "${action.name}" on ${this.model.tablename}`
9610
+ );
9611
+ }
9612
+ this.actions.set(action.name, action);
9613
+ }
9614
+ const known = new Set(this.columnNames());
9615
+ for (const [option, names] of [
9616
+ ["listDisplay", this.listDisplayOverride ?? []],
9617
+ ["listFilter", this.listFilter],
9618
+ ["searchFields", this.searchFields],
9619
+ ["readonlyFields", this.readonlyFields],
9620
+ ["identityField", [this.identityField]]
9621
+ ]) {
9622
+ for (const name of names) {
9623
+ if (!known.has(name)) {
9624
+ throw new Error(
9625
+ `AdminModel(${this.model.tablename}).${option} references unknown column "${name}"; available: ${[...known].join(", ")}`
9626
+ );
9627
+ }
9628
+ }
9629
+ }
9630
+ const ordering = options.ordering ?? (known.has("createdAt") ? "-createdAt" : void 0);
9631
+ if (ordering === void 0) {
9632
+ this.orderKey = null;
9633
+ this.orderAscending = true;
9634
+ } else {
9635
+ const descending = ordering.startsWith("-");
9636
+ const key = descending ? ordering.slice(1) : ordering;
9637
+ if (!known.has(key)) {
9638
+ throw new Error(
9639
+ `AdminModel(${this.model.tablename}).ordering references unknown column "${key}"`
9640
+ );
9641
+ }
9642
+ this.orderKey = key;
9643
+ this.orderAscending = !descending;
9644
+ }
9645
+ }
9646
+ /**
9647
+ * Return the URL slug the model is exposed under.
9648
+ *
9649
+ * @returns The configured slug, or the model's table name.
9650
+ */
9651
+ slug() {
9652
+ return this.slugOverride ?? this.model.tablename;
9653
+ }
9654
+ /**
9655
+ * Return the singular display name.
9656
+ *
9657
+ * @returns The configured name, or the humanized class name without its
9658
+ * trailing `Model`.
9659
+ */
9660
+ verboseName() {
9661
+ if (this.verboseNameOverride !== null) return this.verboseNameOverride;
9662
+ const className = this.model.name;
9663
+ return humanizeField(className.replace(/Model$/, ""));
9664
+ }
9665
+ /**
9666
+ * Return the plural display name.
9667
+ *
9668
+ * @returns The configured plural, or the singular with an `s`.
9669
+ */
9670
+ verboseNamePlural() {
9671
+ return this.verboseNamePluralOverride ?? `${this.verboseName()}s`;
9672
+ }
9673
+ /**
9674
+ * Return every column key on the model, in declaration order.
9675
+ *
9676
+ * @returns The column keys.
9677
+ */
9678
+ columnNames() {
9679
+ return Object.keys(adminColumns(this.model));
9680
+ }
9681
+ /**
9682
+ * Return the columns the list view renders.
9683
+ *
9684
+ * @returns The configured `listDisplay`, or every column but the password hash.
9685
+ */
9686
+ listDisplayNames() {
9687
+ if (this.listDisplayOverride !== null) return [...this.listDisplayOverride];
9688
+ return this.columnNames().filter((name) => !NEVER_LISTED.includes(name));
9689
+ }
9690
+ /**
9691
+ * Return the columns the detail view renders.
9692
+ *
9693
+ * Unlike {@link AdminModel.listDisplayNames}, this is not narrowed by
9694
+ * `listDisplay`: the list view is a scannable summary, but the detail view is
9695
+ * where an operator goes to see the whole record, so trimming it there would
9696
+ * hide data with nowhere else to read it.
9697
+ *
9698
+ * @returns Every column but the password hash, in declaration order.
9699
+ */
9700
+ detailFieldNames() {
9701
+ return this.columnNames().filter((name) => !NEVER_LISTED.includes(name));
9702
+ }
9703
+ /**
9704
+ * Return the columns a create/edit form exposes.
9705
+ *
9706
+ * Excludes the primary key, the managed timestamps, the password hash and
9707
+ * anything listed in `readonlyFields` — none of which a user edits directly
9708
+ * through the generic form.
9709
+ *
9710
+ * @returns The editable column keys, in declaration order.
9711
+ */
9712
+ editableFieldNames() {
9713
+ const skip = /* @__PURE__ */ new Set([...this.readonlyFields, ...NEVER_EDITABLE]);
9714
+ return this.columnNames().filter((name) => !skip.has(name));
9715
+ }
9716
+ /**
9717
+ * Return the registered custom actions, in declaration order.
9718
+ *
9719
+ * @returns The actions passed via `actions` (empty when none). The model
9720
+ * type is erased here, the way {@link AdminSite} erases it when it stores a
9721
+ * configuration — a registry keyed by slug cannot stay generic.
9722
+ */
9723
+ customActions() {
9724
+ return [...this.actions.values()];
9725
+ }
9726
+ /**
9727
+ * Look a custom action up by name.
9728
+ *
9729
+ * @param name - The action identifier (its submitted form value).
9730
+ * @returns The action, or `null` when nothing matches.
9731
+ */
9732
+ getAction(name) {
9733
+ return this.actions.get(name) ?? null;
9734
+ }
9735
+ /**
9736
+ * Build a repository for this model bound to a session.
9737
+ *
9738
+ * @param session - The session the repository runs its statements on.
9739
+ * @returns A repository over {@link AdminModel.model}.
9740
+ */
9741
+ repository(session) {
9742
+ return new tempestDbJs.BaseRepository(this.model, session);
9743
+ }
9744
+ };
9745
+
9746
+ // src/admin/forms.ts
9747
+ function formatFieldValue(widget, value) {
9748
+ if (value === null || value === void 0) return "";
9749
+ if (widget === "json") {
9750
+ return typeof value === "string" ? value : JSON.stringify(value, null, 2);
9751
+ }
9752
+ if (value instanceof Date) {
9753
+ const iso = value.toISOString();
9754
+ if (widget === "date") return iso.slice(0, 10);
9755
+ if (widget === "time") return iso.slice(11, 19);
9756
+ return iso.slice(0, 16);
9757
+ }
9758
+ if (widget === "datetime" || widget === "date") {
9759
+ const parsed = new Date(String(value));
9760
+ if (!Number.isNaN(parsed.getTime())) {
9761
+ const iso = parsed.toISOString();
9762
+ return widget === "date" ? iso.slice(0, 10) : iso.slice(0, 16);
9763
+ }
9764
+ }
9765
+ return String(value);
9766
+ }
9767
+ function literalDefault(column6) {
9768
+ const fallback = column6.defaultValue;
9769
+ if (fallback === null || fallback.kind !== "literal") return void 0;
9770
+ return fallback.value;
9771
+ }
9772
+ function buildFormFields(admin, options = {}) {
9773
+ const columns = adminColumns(admin.model);
9774
+ const values = options.values ?? {};
9775
+ const errors = options.errors ?? {};
9776
+ const foreignKeys = options.foreignKeyOptions ?? {};
9777
+ return admin.editableFieldNames().flatMap((name) => {
9778
+ const column6 = columns[name];
9779
+ if (column6 === void 0) return [];
9780
+ const related = foreignKeys[name];
9781
+ const spec = related === void 0 ? widgetForColumn(column6) : { widget: "select", step: null, options: related };
9782
+ const raw = name in values ? values[name] : literalDefault(column6);
9783
+ return [
9784
+ {
9785
+ name,
9786
+ label: humanizeField(name),
9787
+ widget: spec.widget,
9788
+ value: spec.widget === "checkbox" ? "" : formatFieldValue(spec.widget, raw),
9789
+ required: !isColumnOptional(column6),
9790
+ checked: spec.widget === "checkbox" && toBoolean(raw),
9791
+ step: spec.step,
9792
+ options: spec.options,
9793
+ error: errors[name] ?? null
9794
+ }
9795
+ ];
9796
+ });
9797
+ }
9798
+ function toBoolean(value) {
9799
+ if (typeof value === "boolean") return value;
9800
+ if (typeof value === "number") return value !== 0;
9801
+ if (typeof value !== "string") return false;
9802
+ return ["true", "on", "yes", "1"].includes(value.trim().toLowerCase());
9803
+ }
9804
+ function coerceValue(column6, widget, raw) {
9805
+ const { kind, meta } = column6.type;
9806
+ switch (widget) {
9807
+ case "number": {
9808
+ const parsed = Number(raw);
9809
+ if (!Number.isFinite(parsed)) throw new Error("Enter a valid number.");
9810
+ if (kind === "bigint") return BigInt(raw);
9811
+ if (kind === "smallint" || kind === "integer") {
9812
+ if (!Number.isInteger(parsed)) throw new Error("Enter a whole number.");
9813
+ return parsed;
9814
+ }
9815
+ if (kind === "numeric") return raw;
9816
+ return parsed;
9817
+ }
9818
+ case "datetime":
9819
+ case "date": {
9820
+ const parsed = new Date(raw);
9821
+ if (Number.isNaN(parsed.getTime())) throw new Error("Enter a valid date.");
9822
+ return parsed;
9823
+ }
9824
+ case "json":
9825
+ try {
9826
+ return JSON.parse(raw);
9827
+ } catch {
9828
+ throw new Error("Enter valid JSON.");
9829
+ }
9830
+ case "select": {
9831
+ const allowed = kind === "enum" ? meta.values ?? [] : [];
9832
+ if (allowed.length > 0 && !allowed.includes(raw)) {
9833
+ throw new Error(`Choose one of: ${allowed.join(", ")}.`);
9834
+ }
9835
+ return raw;
9836
+ }
9837
+ default:
9838
+ return raw;
9839
+ }
9840
+ }
9841
+ function parseFormBody(admin, body) {
9842
+ const columns = adminColumns(admin.model);
9843
+ const data = {};
9844
+ const errors = {};
9845
+ for (const name of admin.editableFieldNames()) {
9846
+ const column6 = columns[name];
9847
+ if (column6 === void 0) continue;
9848
+ const { widget } = widgetForColumn(column6);
9849
+ if (widget === "checkbox") {
9850
+ data[name] = toBoolean(body[name]);
9851
+ continue;
9852
+ }
9853
+ const submitted = body[name];
9854
+ const raw = typeof submitted === "string" ? submitted.trim() : "";
9855
+ if (raw === "") {
9856
+ if (!isColumnOptional(column6)) {
9857
+ errors[name] = "This field is required.";
9858
+ continue;
9859
+ }
9860
+ if (column6.flags.hasDefault && !(name in body)) continue;
9861
+ data[name] = null;
9862
+ continue;
9863
+ }
9864
+ try {
9865
+ data[name] = coerceValue(column6, widget, raw);
9866
+ } catch (error) {
9867
+ errors[name] = error instanceof Error ? error.message : "Invalid value.";
9868
+ }
9869
+ }
9870
+ return { data, errors };
9871
+ }
9872
+ function formatCellValue(value) {
9873
+ if (value === null || value === void 0) return "";
9874
+ if (value instanceof Date) return value.toISOString().replace("T", " ").slice(0, 19);
9875
+ if (typeof value === "boolean") return value ? "Yes" : "No";
9876
+ if (typeof value === "object") return JSON.stringify(value);
9877
+ return String(value);
9878
+ }
9879
+ function foreignKeyFields(admin) {
9880
+ const columns = adminColumns(admin.model);
9881
+ const out = {};
9882
+ for (const name of admin.editableFieldNames()) {
9883
+ const column6 = columns[name];
9884
+ if (column6 === void 0) continue;
9885
+ const table = foreignKeyTable(column6);
9886
+ if (table !== null) out[name] = table;
9887
+ }
9888
+ return out;
9889
+ }
9890
+ function foreignKeyLabel(admin, row) {
9891
+ for (const field of admin.searchFields) {
9892
+ const value = row[field];
9893
+ if (typeof value === "string" && value !== "") return value;
9894
+ }
9895
+ for (const field of ["name", "title", "email", "label", "reference"]) {
9896
+ const value = row[field];
9897
+ if (typeof value === "string" && value !== "") return value;
9898
+ }
9899
+ return String(row[admin.identityField] ?? "");
9900
+ }
9901
+
9902
+ // src/admin/auth.ts
9903
+ var UserModelAuthBackend = class {
9904
+ model;
9905
+ passwords;
9906
+ mfa;
9907
+ identifierField;
9908
+ requireAdmin;
9909
+ /**
9910
+ * Build the backend.
9911
+ *
9912
+ * @param model - The user model class (a `BaseUserModel` subclass).
9913
+ * @param options - Hasher, MFA verifier and gating overrides.
9914
+ */
9915
+ constructor(model, options = {}) {
9916
+ this.model = model;
9917
+ this.passwords = options.passwords ?? new PasswordUtils();
9918
+ this.mfa = options.mfa ?? null;
9919
+ this.identifierField = options.identifierField ?? "email";
9920
+ this.requireAdmin = options.requireAdmin ?? true;
9921
+ }
9922
+ /**
9923
+ * Whether a row is allowed into the panel at all.
9924
+ *
9925
+ * @param row - The candidate row.
9926
+ * @returns `true` when the row is active and (when required) an admin.
9927
+ */
9928
+ admits(row) {
9929
+ if (row.isActive === false) return false;
9930
+ return !this.requireAdmin || row.isAdmin === true;
9931
+ }
9932
+ /**
9933
+ * Verify an identifier/password pair.
9934
+ *
9935
+ * The identifier is lowercased and trimmed before lookup, matching the
9936
+ * normalization the auth service applies on signup.
9937
+ *
9938
+ * @param session - A DB session for the current request.
9939
+ * @param identifier - The submitted identifier.
9940
+ * @param password - The submitted plaintext password.
9941
+ * @returns The matching row, or `null` when it does not qualify.
9942
+ */
9943
+ async authenticate(session, identifier, password) {
9944
+ const repository = new tempestDbJs.BaseRepository(this.model, session);
9945
+ const filters = { [this.identifierField]: identifier.trim().toLowerCase() };
9946
+ const row = await repository.first(
9947
+ filters
9948
+ );
9949
+ if (row === null || !this.admits(row)) return null;
9950
+ if (!await this.passwords.verify(password, row.hashedPassword)) return null;
9951
+ return row;
9952
+ }
9953
+ /**
9954
+ * Re-load the principal a session points at.
9955
+ *
9956
+ * @param session - A DB session for the current request.
9957
+ * @param subject - The principal id from the session cookie.
9958
+ * @returns The row, or `null` when it vanished or lost its privileges.
9959
+ */
9960
+ async loadPrincipal(session, subject) {
9961
+ const repository = new tempestDbJs.BaseRepository(this.model, session);
9962
+ const row = await repository.getByIdOrNull(
9963
+ subject
9964
+ );
9965
+ if (row === null || !this.admits(row)) return null;
9966
+ return row;
9967
+ }
9968
+ /**
9969
+ * Return the row's primary key.
9970
+ *
9971
+ * @param principal - The authenticated row.
9972
+ * @returns The principal id.
9973
+ */
9974
+ principalId(principal) {
9975
+ return String(principal.id);
9976
+ }
9977
+ /**
9978
+ * Return the label shown in the panel header.
9979
+ *
9980
+ * @param principal - The authenticated row.
9981
+ * @returns The identifier column's value.
9982
+ */
9983
+ displayName(principal) {
9984
+ const value = principal[this.identifierField];
9985
+ return typeof value === "string" ? value : String(principal.id);
9986
+ }
9987
+ /**
9988
+ * Whether the principal enrolled a second factor.
9989
+ *
9990
+ * @param principal - The authenticated row.
9991
+ * @returns `true` when an MFA verifier is configured and reports a secret.
9992
+ */
9993
+ async mfaEnabled(principal) {
9994
+ if (this.mfa === null) return false;
9995
+ return await this.mfa.isEnabled(String(principal.id));
9996
+ }
9997
+ /**
9998
+ * Verify a submitted TOTP code.
9999
+ *
10000
+ * @param principal - The authenticated row.
10001
+ * @param code - The submitted code.
10002
+ * @returns `true` when the code is valid.
10003
+ */
10004
+ async verifyMfa(principal, code) {
10005
+ if (this.mfa === null) return false;
10006
+ return await this.mfa.verify(String(principal.id), code);
10007
+ }
10008
+ };
10009
+ var MIN_SECRET_LENGTH = 32;
10010
+ function encode(value) {
10011
+ return Buffer.from(value, "utf8").toString("base64url");
10012
+ }
10013
+ var AdminSessionStore = class {
10014
+ secret;
10015
+ cookieName;
10016
+ maxAgeSeconds;
10017
+ cookieSecure;
10018
+ cookiePath;
10019
+ /**
10020
+ * Build the store.
10021
+ *
10022
+ * @param options - Secret, cookie name, lifetime and cookie flags.
10023
+ * @throws Error When the secret is shorter than 32 characters.
10024
+ */
10025
+ constructor(options) {
10026
+ if (options.secret.length < MIN_SECRET_LENGTH) {
10027
+ throw new Error(
10028
+ `Admin session secret must be at least ${MIN_SECRET_LENGTH} characters; got ${options.secret.length}`
10029
+ );
10030
+ }
10031
+ this.secret = options.secret;
10032
+ this.cookieName = options.cookieName ?? "tempest_admin_session";
10033
+ this.maxAgeSeconds = options.maxAgeSeconds ?? 8 * 60 * 60;
10034
+ this.cookieSecure = options.cookieSecure ?? true;
10035
+ this.cookiePath = options.cookiePath ?? "/";
10036
+ }
10037
+ /**
10038
+ * Sign a payload.
10039
+ *
10040
+ * @param payload - The base64url payload to sign.
10041
+ * @returns The base64url signature.
10042
+ */
10043
+ sign(payload) {
10044
+ return crypto.createHmac("sha256", this.secret).update(payload).digest("base64url");
10045
+ }
10046
+ /**
10047
+ * Mint a fresh session for an authenticated principal.
10048
+ *
10049
+ * @param subject - The principal id.
10050
+ * @param displayName - The name shown in the header.
10051
+ * @param mfaPassed - Whether the second factor is already satisfied.
10052
+ * @returns The new session payload (not yet written to a response).
10053
+ */
10054
+ issue(subject, displayName, mfaPassed = true) {
10055
+ return {
10056
+ subject,
10057
+ displayName,
10058
+ csrfToken: crypto.randomUUID(),
10059
+ expiresAt: Math.floor(Date.now() / 1e3) + this.maxAgeSeconds,
10060
+ mfaPassed
10061
+ };
10062
+ }
10063
+ /**
10064
+ * Read and verify the session carried by a request.
10065
+ *
10066
+ * @param req - The inbound request.
10067
+ * @returns The session, or `null` when absent, tampered with or expired.
10068
+ */
10069
+ load(req) {
10070
+ const raw = parseCookies(req.header("cookie") ?? void 0)[this.cookieName];
10071
+ if (raw === void 0) return null;
10072
+ const separator = raw.lastIndexOf(".");
10073
+ if (separator <= 0) return null;
10074
+ const payload = raw.slice(0, separator);
10075
+ const signature = Buffer.from(raw.slice(separator + 1), "base64url");
10076
+ const expected = Buffer.from(this.sign(payload), "base64url");
10077
+ if (signature.length !== expected.length) return null;
10078
+ if (!crypto.timingSafeEqual(signature, expected)) return null;
10079
+ let session;
10080
+ try {
10081
+ session = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
10082
+ } catch {
10083
+ return null;
10084
+ }
10085
+ if (typeof session.subject !== "string" || typeof session.csrfToken !== "string") {
10086
+ return null;
10087
+ }
10088
+ if (session.expiresAt <= Math.floor(Date.now() / 1e3)) return null;
10089
+ return session;
10090
+ }
10091
+ /**
10092
+ * Write a session to the response as a signed cookie.
10093
+ *
10094
+ * @param res - The outbound response.
10095
+ * @param session - The session to persist.
10096
+ */
10097
+ save(res, session) {
10098
+ const payload = encode(JSON.stringify(session));
10099
+ const value = `${payload}.${this.sign(payload)}`;
10100
+ const maxAge = Math.max(0, session.expiresAt - Math.floor(Date.now() / 1e3));
10101
+ res.append("set-cookie", this.cookie(value, maxAge));
10102
+ }
10103
+ /**
10104
+ * Drop the session cookie.
10105
+ *
10106
+ * @param res - The outbound response.
10107
+ */
10108
+ clear(res) {
10109
+ res.append("set-cookie", this.cookie("", 0));
10110
+ }
10111
+ /**
10112
+ * Render a `Set-Cookie` value with the configured flags.
10113
+ *
10114
+ * @param value - The cookie value.
10115
+ * @param maxAge - Lifetime in seconds (`0` expires it immediately).
10116
+ * @returns The header value.
10117
+ */
10118
+ cookie(value, maxAge) {
10119
+ const parts = [
10120
+ `${this.cookieName}=${value}`,
10121
+ `Path=${this.cookiePath}`,
10122
+ `Max-Age=${maxAge}`,
10123
+ "HttpOnly",
10124
+ "SameSite=Lax"
10125
+ ];
10126
+ if (this.cookieSecure) parts.push("Secure");
10127
+ return parts.join("; ");
10128
+ }
10129
+ };
10130
+ function csrfTokenMatches(session, submitted) {
10131
+ if (typeof submitted !== "string") return false;
10132
+ const expected = Buffer.from(session.csrfToken, "utf8");
10133
+ const actual = Buffer.from(submitted, "utf8");
10134
+ if (expected.length !== actual.length) return false;
10135
+ return crypto.timingSafeEqual(expected, actual);
10136
+ }
10137
+
9447
10138
  // src/admin/site.ts
10139
+ function isConcreteModel(value) {
10140
+ if (typeof value !== "function") return false;
10141
+ const proto = value.prototype;
10142
+ if (typeof proto !== "object" || proto === null) return false;
10143
+ if (!(proto instanceof tempestDbJs.Model)) return false;
10144
+ const tablename = value.tablename;
10145
+ return typeof tablename === "string" && tablename.length > 0;
10146
+ }
9448
10147
  var AdminSite = class {
10148
+ /** Text used in the page `<title>` and the dashboard heading. */
10149
+ title;
10150
+ /** Centered header brand, or `null` to fall back to {@link AdminSite.title}. */
10151
+ brand;
10152
+ /** Dashboard subtitle. */
10153
+ indexSubtitle;
10154
+ /** Outbound "View site" link, or `null`. */
10155
+ siteUrl;
10156
+ /** Typed appearance overrides. */
10157
+ theme;
10158
+ registry = /* @__PURE__ */ new Map();
10159
+ /**
10160
+ * Initialize the site.
10161
+ *
10162
+ * @param options - Branding and appearance. See {@link AdminSiteOptions}.
10163
+ */
10164
+ constructor(options = {}) {
10165
+ this.title = options.title ?? "Admin";
10166
+ this.brand = options.brand ?? null;
10167
+ this.indexSubtitle = options.indexSubtitle ?? "Site administration";
10168
+ this.siteUrl = options.siteUrl ?? null;
10169
+ this.theme = options.theme ?? {};
10170
+ }
10171
+ /**
10172
+ * Return the centered header brand text.
10173
+ *
10174
+ * @returns {@link AdminSite.brand} when set, otherwise {@link AdminSite.title}.
10175
+ */
10176
+ brandText() {
10177
+ return this.brand ?? this.title;
10178
+ }
10179
+ /**
10180
+ * Register a model configuration under its slug.
10181
+ *
10182
+ * @param admin - An {@link AdminModel} instance, or the options to build one.
10183
+ * @returns The registered instance, so the call can be chained or assigned.
10184
+ * @throws Error When another configuration already holds the same slug.
10185
+ */
10186
+ register(admin) {
10187
+ const config = admin instanceof AdminModel ? admin : new AdminModel(admin);
10188
+ const slug = config.slug();
10189
+ const existing = this.registry.get(slug);
10190
+ if (existing !== void 0) {
10191
+ throw new Error(
10192
+ `AdminModel for slug "${slug}" is already registered (${existing.model.tablename}); refusing to overwrite with ${config.model.tablename}`
10193
+ );
10194
+ }
10195
+ this.registry.set(slug, config);
10196
+ return config;
10197
+ }
10198
+ /**
10199
+ * Remove a previously registered configuration.
10200
+ *
10201
+ * @param slug - The slug to drop.
10202
+ * @throws Error When no configuration is registered under the slug.
10203
+ */
10204
+ unregister(slug) {
10205
+ if (!this.registry.delete(slug)) {
10206
+ throw new Error(`No AdminModel registered for slug "${slug}"`);
10207
+ }
10208
+ }
10209
+ /**
10210
+ * Look a configuration up by slug.
10211
+ *
10212
+ * @param slug - The admin slug.
10213
+ * @returns The configuration, or `null` when nothing matches.
10214
+ */
10215
+ get(slug) {
10216
+ return this.registry.get(slug) ?? null;
10217
+ }
10218
+ /**
10219
+ * Return every registered configuration, ordered by display name.
10220
+ *
10221
+ * @returns The configurations (empty when nothing is registered).
10222
+ */
10223
+ list() {
10224
+ return [...this.registry.values()].sort(
10225
+ (left, right) => left.verboseNamePlural().toLowerCase().localeCompare(right.verboseNamePlural().toLowerCase())
10226
+ );
10227
+ }
10228
+ /**
10229
+ * Register every concrete model found in `source` at once.
10230
+ *
10231
+ * The batch counterpart to {@link AdminSite.register}: instead of one call
10232
+ * per table, hand it the models barrel and every model class declaring a
10233
+ * `tablename` is wrapped in a default {@link AdminModel}.
10234
+ *
10235
+ * ```ts
10236
+ * import * as models from "./db/models";
10237
+ *
10238
+ * site.automap(models);
10239
+ * site.automap([UserModel, OrderModel], { pageSize: 50 });
10240
+ * ```
10241
+ *
10242
+ * @param source - An array of model classes, or a module namespace object
10243
+ * whose values are swept (non-model entries are ignored).
10244
+ * @param options - `exclude`, `skipRegistered` and any {@link AdminModel}
10245
+ * option applied uniformly to every model discovered here.
10246
+ * @returns The configurations newly registered by this call.
10247
+ * @throws Error When `skipRegistered` is `false` and a slug collides.
10248
+ */
10249
+ automap(source, options = {}) {
10250
+ const { exclude = [], skipRegistered = true, ...adminOptions } = options;
10251
+ const excluded = new Set(
10252
+ exclude.map((entry) => typeof entry === "string" ? entry : entry.tablename)
10253
+ );
10254
+ const candidates = Array.isArray(source) ? source : Object.values(source);
10255
+ const registered = [];
10256
+ for (const candidate of candidates) {
10257
+ if (!isConcreteModel(candidate)) continue;
10258
+ if (excluded.has(candidate.tablename)) continue;
10259
+ const config = new AdminModel({ ...adminOptions, model: candidate });
10260
+ if (skipRegistered && this.registry.get(config.slug()) !== void 0) continue;
10261
+ registered.push(this.register(config));
10262
+ }
10263
+ return registered.sort((left, right) => left.slug().localeCompare(right.slug()));
10264
+ }
10265
+ };
10266
+
10267
+ // src/admin/styles.ts
10268
+ var ADMIN_CSS = `:root {
10269
+ --tempest-bg: #0f172a;
10270
+ --tempest-bg-soft: #1e293b;
10271
+ --tempest-bg-row: #f8fafc;
10272
+ --tempest-bg-row-alt: #f1f5f9;
10273
+ --tempest-fg: #0f172a;
10274
+ --tempest-fg-soft: #475569;
10275
+ --tempest-accent: #2563eb;
10276
+ --tempest-accent-hover: #1d4ed8;
10277
+ --tempest-accent-soft: rgba(37, 99, 235, 0.1);
10278
+ --tempest-danger: #b91c1c;
10279
+ /* Light surface + hairline used by cards / tables / dropdowns on the
10280
+ (light) content area \u2014 distinct from the dark sidebar bg vars above.
10281
+ Overridable by AdminTheme for dark mode. */
10282
+ --tempest-surface: #ffffff;
10283
+ --tempest-border: #e2e8f0;
10284
+ --tempest-radius: 6px;
10285
+ --tempest-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
10286
+ }
10287
+
10288
+ * {
10289
+ box-sizing: border-box;
10290
+ }
10291
+
10292
+ body {
10293
+ margin: 0;
10294
+ font-family: var(--tempest-font, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
10295
+ font-size: 14px;
10296
+ color: var(--tempest-fg);
10297
+ background: var(--tempest-page-bg, #f8fafc);
10298
+ /* Sticky-footer column so the layout (and its sidebar) fills the
10299
+ viewport even when the page content is short. */
10300
+ min-height: 100vh;
10301
+ display: flex;
10302
+ flex-direction: column;
10303
+ }
10304
+
10305
+ a {
10306
+ color: var(--tempest-accent);
10307
+ text-decoration: none;
10308
+ }
10309
+
10310
+ a:hover {
10311
+ color: var(--tempest-accent-hover);
10312
+ text-decoration: underline;
10313
+ }
10314
+
10315
+ button {
10316
+ border: 0;
10317
+ background: var(--tempest-accent);
10318
+ color: #fff;
10319
+ padding: 0.5rem 1rem;
10320
+ border-radius: var(--tempest-radius);
10321
+ cursor: pointer;
10322
+ font: inherit;
10323
+ }
10324
+
10325
+ button:hover {
10326
+ background: var(--tempest-accent-hover);
10327
+ }
10328
+
10329
+ input,
10330
+ select,
10331
+ textarea {
10332
+ font: inherit;
10333
+ padding: 0.45rem 0.6rem;
10334
+ border-radius: var(--tempest-radius);
10335
+ border: 1px solid #cbd5e1;
10336
+ background: #fff;
10337
+ }
10338
+
10339
+ .tempest-admin-header {
10340
+ position: relative;
10341
+ z-index: 20;
10342
+ background: var(--tempest-bg);
10343
+ color: #f8fafc;
10344
+ padding: 0.75rem 1.5rem;
10345
+ display: flex;
10346
+ align-items: center;
10347
+ justify-content: space-between;
10348
+ box-shadow: var(--tempest-shadow);
10349
+ }
10350
+
10351
+ /* Centered brand \u2014 pinned to the middle of the header (i.e. the screen)
10352
+ regardless of the left (burger) / right (nav) cluster widths. */
10353
+ .tempest-admin-header__brand {
10354
+ position: absolute;
10355
+ left: 50%;
10356
+ transform: translateX(-50%);
10357
+ text-align: center;
10358
+ pointer-events: none;
10359
+ }
10360
+
10361
+ .tempest-admin-header__brand a {
10362
+ color: #fff;
10363
+ font-weight: 600;
10364
+ font-size: 1.1rem;
10365
+ pointer-events: auto;
10366
+ display: inline-flex;
10367
+ align-items: center;
10368
+ }
10369
+
10370
+ /* Logo image (shown instead of the brand text when theme.logo_url is
10371
+ set). Capped to the header height so any aspect ratio fits cleanly. */
10372
+ .tempest-admin-header__logo {
10373
+ display: block;
10374
+ max-height: 32px;
10375
+ width: auto;
10376
+ }
10377
+
10378
+ .tempest-admin-header__nav {
10379
+ display: flex;
10380
+ align-items: center;
10381
+ gap: 1rem;
10382
+ color: #cbd5e1;
10383
+ }
10384
+
10385
+ .tempest-admin-header__nav a {
10386
+ color: #cbd5e1;
10387
+ }
10388
+
10389
+ .tempest-admin-header__user {
10390
+ font-weight: 500;
10391
+ color: #f1f5f9;
10392
+ }
10393
+
10394
+ .tempest-admin-header__logout {
10395
+ margin: 0;
10396
+ }
10397
+
10398
+ .tempest-admin-header__logout button {
10399
+ background: transparent;
10400
+ color: #f1f5f9;
10401
+ padding: 0.25rem 0.5rem;
10402
+ border: 1px solid #475569;
10403
+ }
10404
+
10405
+ .tempest-admin-header__logout button:hover {
10406
+ background: rgba(255, 255, 255, 0.08);
10407
+ }
10408
+
10409
+ .tempest-admin-header__left {
10410
+ display: flex;
10411
+ align-items: center;
10412
+ gap: 0.75rem;
10413
+ }
10414
+
10415
+ /* ---- Layout: persistent sidebar + content ---- */
10416
+ .tempest-admin-layout {
10417
+ display: flex;
10418
+ align-items: stretch;
10419
+ flex: 1; /* fill the viewport so the sidebar is full-height */
10420
+ }
10421
+
10422
+ .tempest-admin-sidebar {
10423
+ flex: 0 0 220px;
10424
+ width: 220px;
10425
+ background: var(--tempest-bg-soft);
10426
+ color: #e2e8f0;
10427
+ padding: 1rem 0.75rem;
10428
+ }
10429
+
10430
+ .tempest-admin-sidebar__nav {
10431
+ display: flex;
10432
+ flex-direction: column;
10433
+ gap: 0.15rem;
10434
+ position: sticky;
10435
+ top: 1rem;
10436
+ }
10437
+
10438
+ .tempest-admin-sidebar__heading {
10439
+ font-size: 0.7rem;
10440
+ text-transform: uppercase;
10441
+ letter-spacing: 0.06em;
10442
+ color: #94a3b8;
10443
+ margin: 1rem 0.75rem 0.35rem;
10444
+ }
10445
+
10446
+ .tempest-admin-sidebar__link {
10447
+ display: block;
10448
+ padding: 0.5rem 0.75rem;
10449
+ border-radius: var(--tempest-radius);
10450
+ color: #cbd5e1;
10451
+ }
10452
+
10453
+ .tempest-admin-sidebar__link:hover {
10454
+ background: rgba(255, 255, 255, 0.06);
10455
+ color: #fff;
10456
+ text-decoration: none;
10457
+ }
10458
+
10459
+ .tempest-admin-sidebar__link--active,
10460
+ .tempest-admin-sidebar__link--active:hover {
10461
+ background: var(--tempest-accent);
10462
+ color: #fff;
10463
+ }
10464
+
10465
+ /* Burger toggle (CSS-only, via the hidden checkbox) \u2014 mobile only. */
10466
+ .tempest-admin-burger {
10467
+ display: none;
10468
+ flex-direction: column;
10469
+ gap: 4px;
10470
+ cursor: pointer;
10471
+ padding: 0.3rem;
10472
+ }
10473
+
10474
+ .tempest-admin-burger span {
10475
+ display: block;
10476
+ width: 22px;
10477
+ height: 2px;
10478
+ background: #f8fafc;
10479
+ border-radius: 2px;
10480
+ }
10481
+
10482
+ .tempest-admin-scrim {
10483
+ display: none;
10484
+ }
10485
+
10486
+ .tempest-admin-main {
10487
+ flex: 1 1 auto;
10488
+ min-width: 0;
10489
+ max-width: 1080px;
10490
+ margin: 2rem auto;
10491
+ padding: 0 1.5rem;
10492
+ }
10493
+
10494
+ .tempest-admin-messages {
10495
+ list-style: none;
10496
+ padding: 0;
10497
+ margin: 0 0 1.5rem;
10498
+ display: flex;
10499
+ flex-direction: column;
10500
+ gap: 0.5rem;
10501
+ }
10502
+
10503
+ .tempest-admin-messages__item {
10504
+ padding: 0.75rem 1rem;
10505
+ border-radius: var(--tempest-radius);
10506
+ border-left: 4px solid var(--tempest-accent);
10507
+ background: #eff6ff;
10508
+ }
10509
+
10510
+ .tempest-admin-messages__item--error {
10511
+ border-left-color: var(--tempest-danger);
10512
+ background: #fef2f2;
10513
+ }
10514
+
10515
+ .tempest-admin-login {
10516
+ max-width: 360px;
10517
+ margin: 4rem auto;
10518
+ background: #fff;
10519
+ border-radius: var(--tempest-radius);
10520
+ padding: 2rem;
10521
+ box-shadow: var(--tempest-shadow);
10522
+ }
10523
+
10524
+ .tempest-admin-login h1 {
10525
+ margin-top: 0;
10526
+ }
10527
+
10528
+ .tempest-admin-login__form {
10529
+ display: flex;
10530
+ flex-direction: column;
10531
+ gap: 1rem;
10532
+ }
10533
+
10534
+ .tempest-admin-login__form label {
10535
+ display: flex;
10536
+ flex-direction: column;
10537
+ gap: 0.25rem;
10538
+ }
10539
+
10540
+ .tempest-admin-login__error {
10541
+ background: #fef2f2;
10542
+ color: var(--tempest-danger);
10543
+ padding: 0.75rem 1rem;
10544
+ border-radius: var(--tempest-radius);
10545
+ }
10546
+
10547
+ .tempest-admin-dashboard__table,
10548
+ .tempest-admin-list__table {
10549
+ width: 100%;
10550
+ border-collapse: collapse;
10551
+ background: #fff;
10552
+ border-radius: var(--tempest-radius);
10553
+ overflow: hidden;
10554
+ box-shadow: var(--tempest-shadow);
10555
+ }
10556
+
10557
+ .tempest-admin-dashboard__table th,
10558
+ .tempest-admin-dashboard__table td,
10559
+ .tempest-admin-list__table th,
10560
+ .tempest-admin-list__table td {
10561
+ padding: 0.75rem 1rem;
10562
+ text-align: left;
10563
+ border-bottom: 1px solid #e2e8f0;
10564
+ vertical-align: top;
10565
+ }
10566
+
10567
+ .tempest-admin-list__table tr:nth-child(even) td {
10568
+ background: var(--tempest-bg-row-alt);
10569
+ }
10570
+
10571
+ .tempest-admin-list__header {
10572
+ display: flex;
10573
+ justify-content: space-between;
10574
+ align-items: baseline;
10575
+ margin-bottom: 1rem;
10576
+ }
10577
+
10578
+ .tempest-admin-list__filters {
10579
+ display: flex;
10580
+ gap: 0.75rem;
10581
+ align-items: end;
10582
+ margin-bottom: 1rem;
10583
+ flex-wrap: wrap;
10584
+ }
10585
+
10586
+ .tempest-admin-list__filters label {
10587
+ display: flex;
10588
+ flex-direction: column;
10589
+ gap: 0.25rem;
10590
+ }
10591
+
10592
+ .tempest-admin-list__pagination {
10593
+ display: flex;
10594
+ gap: 1rem;
10595
+ align-items: center;
10596
+ justify-content: center;
10597
+ margin-top: 1.5rem;
10598
+ color: var(--tempest-fg-soft);
10599
+ }
10600
+
10601
+ .tempest-admin-detail__fields {
10602
+ display: grid;
10603
+ grid-template-columns: 200px 1fr;
10604
+ gap: 0.5rem 1.5rem;
10605
+ background: #fff;
10606
+ border-radius: var(--tempest-radius);
10607
+ padding: 1.5rem;
10608
+ box-shadow: var(--tempest-shadow);
10609
+ }
10610
+
10611
+ .tempest-admin-detail__fields dt {
10612
+ font-weight: 600;
10613
+ color: var(--tempest-fg-soft);
10614
+ }
10615
+
10616
+ .tempest-admin-detail__fields dd {
10617
+ margin: 0;
10618
+ word-break: break-word;
10619
+ }
10620
+
10621
+ .tempest-admin-detail__audit-title {
10622
+ font-size: 1rem;
10623
+ margin: 1.5rem 0 0.5rem;
10624
+ color: var(--tempest-fg-soft);
10625
+ }
10626
+
10627
+ .tempest-admin-detail__json {
10628
+ margin: 0;
10629
+ padding: 0.5rem 0.75rem;
10630
+ background: var(--tempest-bg-row);
10631
+ border: 1px solid var(--tempest-border);
10632
+ border-radius: var(--tempest-radius);
10633
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
10634
+ font-size: 0.8rem;
10635
+ overflow-x: auto;
10636
+ }
10637
+
10638
+ .tempest-admin-history {
10639
+ list-style: none;
10640
+ margin: 0;
10641
+ padding: 0;
10642
+ border-left: 2px solid var(--tempest-border);
10643
+ }
10644
+
10645
+ .tempest-admin-history__item {
10646
+ position: relative;
10647
+ padding: 0.5rem 0 1rem 1rem;
10648
+ }
10649
+
10650
+ .tempest-admin-history__item::before {
10651
+ content: "";
10652
+ position: absolute;
10653
+ left: -0.4rem;
10654
+ top: 0.9rem;
10655
+ width: 0.6rem;
10656
+ height: 0.6rem;
10657
+ border-radius: 50%;
10658
+ background: var(--tempest-fg-soft);
10659
+ }
10660
+
10661
+ .tempest-admin-history__item--create::before { background: #16a34a; }
10662
+ .tempest-admin-history__item--update::before { background: #d97706; }
10663
+ .tempest-admin-history__item--delete::before { background: #dc2626; }
10664
+
10665
+ .tempest-admin-history__head {
10666
+ display: flex;
10667
+ gap: 0.75rem;
10668
+ align-items: baseline;
10669
+ flex-wrap: wrap;
10670
+ }
10671
+
10672
+ .tempest-admin-history__action {
10673
+ font-weight: 600;
10674
+ text-transform: uppercase;
10675
+ font-size: 0.75rem;
10676
+ letter-spacing: 0.04em;
10677
+ }
10678
+
10679
+ .tempest-admin-history__meta {
10680
+ color: var(--tempest-fg-soft);
10681
+ font-size: 0.85rem;
10682
+ }
10683
+
10684
+ .tempest-admin-history__changes {
10685
+ width: 100%;
10686
+ border-collapse: collapse;
10687
+ margin-top: 0.5rem;
10688
+ font-size: 0.85rem;
10689
+ }
10690
+
10691
+ .tempest-admin-history__changes th,
10692
+ .tempest-admin-history__changes td {
10693
+ text-align: left;
10694
+ padding: 0.25rem 0.5rem;
10695
+ border-bottom: 1px solid var(--tempest-border);
10696
+ vertical-align: top;
10697
+ word-break: break-word;
10698
+ }
10699
+
10700
+ .tempest-admin-history__changes th {
10701
+ color: var(--tempest-fg-soft);
10702
+ font-weight: 600;
10703
+ }
10704
+
10705
+ .tempest-admin-history__empty {
10706
+ color: var(--tempest-fg-soft);
10707
+ }
10708
+
10709
+ .tempest-admin-ac {
10710
+ position: relative;
10711
+ }
10712
+
10713
+ .tempest-admin-ac__search {
10714
+ width: 100%;
10715
+ }
10716
+
10717
+ .tempest-admin-ac__results {
10718
+ list-style: none;
10719
+ margin: 0.25rem 0 0;
10720
+ padding: 0;
10721
+ border: 1px solid var(--tempest-border);
10722
+ border-radius: var(--tempest-radius, 6px);
10723
+ max-height: 12rem;
10724
+ overflow-y: auto;
10725
+ background: var(--tempest-surface);
10726
+ }
10727
+
10728
+ .tempest-admin-ac__results:empty {
10729
+ display: none;
10730
+ }
10731
+
10732
+ .tempest-admin-ac__option {
10733
+ display: block;
10734
+ width: 100%;
10735
+ text-align: left;
10736
+ padding: 0.4rem 0.6rem;
10737
+ background: none;
10738
+ border: 0;
10739
+ cursor: pointer;
10740
+ color: inherit;
10741
+ font: inherit;
10742
+ }
10743
+
10744
+ .tempest-admin-ac__option:hover,
10745
+ .tempest-admin-ac__option:focus {
10746
+ background: var(--tempest-accent-soft, rgba(0, 0, 0, 0.06));
10747
+ }
10748
+
10749
+ .tempest-admin-ac__empty {
10750
+ padding: 0.4rem 0.6rem;
10751
+ color: var(--tempest-fg-soft);
10752
+ }
10753
+
10754
+ .tempest-admin-inline {
10755
+ margin-top: 2rem;
10756
+ }
10757
+
10758
+ .tempest-admin-inline__header {
10759
+ display: flex;
10760
+ align-items: center;
10761
+ justify-content: space-between;
10762
+ gap: 1rem;
10763
+ margin-bottom: 0.5rem;
10764
+ }
10765
+
10766
+ .tempest-admin-inline__header h2 {
10767
+ font-size: 1rem;
10768
+ margin: 0;
10769
+ color: var(--tempest-fg-soft);
10770
+ }
10771
+
10772
+ .tempest-admin-inline__count {
10773
+ font-weight: 400;
10774
+ }
10775
+
10776
+ .tempest-admin-inline__scroll {
10777
+ overflow-x: auto;
10778
+ }
10779
+
10780
+ .tempest-admin-inline__table {
10781
+ width: 100%;
10782
+ border-collapse: collapse;
10783
+ font-size: 0.9rem;
10784
+ }
10785
+
10786
+ .tempest-admin-inline__table th,
10787
+ .tempest-admin-inline__table td {
10788
+ text-align: left;
10789
+ padding: 0.4rem 0.6rem;
10790
+ border-bottom: 1px solid var(--tempest-border);
10791
+ white-space: nowrap;
10792
+ }
10793
+
10794
+ .tempest-admin-inline__table th {
10795
+ color: var(--tempest-fg-soft);
10796
+ font-weight: 600;
10797
+ }
10798
+
10799
+ .tempest-admin-inline__empty,
10800
+ .tempest-admin-inline__more {
10801
+ color: var(--tempest-fg-soft);
10802
+ }
10803
+
10804
+ .tempest-admin-cards {
10805
+ display: grid;
10806
+ grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
10807
+ gap: 1rem;
10808
+ margin: 1rem 0 2rem;
10809
+ }
10810
+
10811
+ .tempest-admin-card {
10812
+ display: flex;
10813
+ flex-direction: column;
10814
+ gap: 0.35rem;
10815
+ padding: 1rem 1.1rem;
10816
+ border: 1px solid var(--tempest-border);
10817
+ border-radius: var(--tempest-radius, 8px);
10818
+ background: var(--tempest-surface);
10819
+ }
10820
+
10821
+ .tempest-admin-card__label {
10822
+ font-size: 0.8rem;
10823
+ color: var(--tempest-fg-soft);
10824
+ text-transform: uppercase;
10825
+ letter-spacing: 0.04em;
10826
+ }
10827
+
10828
+ .tempest-admin-card__value {
10829
+ font-size: 1.7rem;
10830
+ font-weight: 700;
10831
+ line-height: 1.1;
10832
+ }
10833
+
10834
+ .tempest-admin-card__value small {
10835
+ font-size: 0.9rem;
10836
+ font-weight: 400;
10837
+ color: var(--tempest-fg-soft);
10838
+ }
10839
+
10840
+ .tempest-admin-card__trend {
10841
+ font-size: 0.9rem;
10842
+ font-weight: 600;
10843
+ }
10844
+
10845
+ .tempest-admin-card__trend small {
10846
+ font-weight: 400;
10847
+ color: var(--tempest-fg-soft);
10848
+ }
10849
+
10850
+ .tempest-admin-card__trend--up { color: #16a34a; }
10851
+ .tempest-admin-card__trend--down { color: #dc2626; }
10852
+ .tempest-admin-card__trend--flat { color: var(--tempest-fg-soft); }
10853
+
10854
+ .tempest-admin-card__parts {
10855
+ list-style: none;
10856
+ margin: 0.25rem 0 0;
10857
+ padding: 0;
10858
+ display: flex;
10859
+ flex-direction: column;
10860
+ gap: 0.3rem;
10861
+ }
10862
+
10863
+ .tempest-admin-card__parts li {
10864
+ display: grid;
10865
+ grid-template-columns: 6rem 1fr auto;
10866
+ align-items: center;
10867
+ gap: 0.5rem;
10868
+ font-size: 0.85rem;
10869
+ }
10870
+
10871
+ .tempest-admin-card__part-label {
10872
+ overflow: hidden;
10873
+ text-overflow: ellipsis;
10874
+ white-space: nowrap;
10875
+ color: var(--tempest-fg-soft);
10876
+ }
10877
+
10878
+ .tempest-admin-card__part-bar {
10879
+ display: block;
10880
+ height: 0.5rem;
10881
+ border-radius: 999px;
10882
+ background: var(--tempest-border);
10883
+ overflow: hidden;
10884
+ }
10885
+
10886
+ .tempest-admin-card__part-bar span {
10887
+ display: block;
10888
+ height: 100%;
10889
+ background: var(--tempest-accent, #2563eb);
10890
+ }
10891
+
10892
+ .tempest-admin-card__help {
10893
+ font-size: 0.8rem;
10894
+ color: var(--tempest-fg-soft);
10895
+ }
10896
+
10897
+ .tempest-admin-import__result {
10898
+ margin: 1rem 0;
10899
+ }
10900
+
10901
+ .tempest-admin-import__ok {
10902
+ color: #16a34a;
10903
+ font-weight: 600;
10904
+ }
10905
+
10906
+ .tempest-admin-import__failed {
10907
+ color: #d97706;
10908
+ font-weight: 600;
10909
+ }
10910
+
10911
+ .tempest-admin-import__errors {
10912
+ width: 100%;
10913
+ border-collapse: collapse;
10914
+ font-size: 0.85rem;
10915
+ }
10916
+
10917
+ .tempest-admin-import__errors th,
10918
+ .tempest-admin-import__errors td {
10919
+ text-align: left;
10920
+ padding: 0.3rem 0.5rem;
10921
+ border-bottom: 1px solid var(--tempest-border);
10922
+ vertical-align: top;
10923
+ }
10924
+
10925
+ .tempest-admin-import__form label {
10926
+ display: block;
10927
+ margin: 1rem 0 0.5rem;
10928
+ }
10929
+
10930
+ .tempest-admin-lenses {
10931
+ display: flex;
10932
+ flex-wrap: wrap;
10933
+ gap: 0.4rem;
10934
+ margin: 0.5rem 0 1rem;
10935
+ border-bottom: 1px solid var(--tempest-border);
10936
+ padding-bottom: 0.5rem;
10937
+ }
10938
+
10939
+ .tempest-admin-lens {
10940
+ padding: 0.3rem 0.7rem;
10941
+ border-radius: 999px;
10942
+ border: 1px solid var(--tempest-border);
10943
+ color: var(--tempest-fg-soft);
10944
+ text-decoration: none;
10945
+ font-size: 0.85rem;
10946
+ }
10947
+
10948
+ .tempest-admin-lens:hover {
10949
+ color: inherit;
10950
+ }
10951
+
10952
+ .tempest-admin-lens--active {
10953
+ background: var(--tempest-accent, #2563eb);
10954
+ border-color: var(--tempest-accent, #2563eb);
10955
+ color: #fff;
10956
+ }
10957
+
10958
+ .tempest-admin-footer {
10959
+ position: relative;
10960
+ z-index: 20;
10961
+ text-align: center;
10962
+ padding: 2rem 0;
10963
+ color: var(--tempest-fg-soft);
10964
+ }
10965
+
10966
+ /* List toolbar: search/filters on the left, actions (export) on the right. */
10967
+ .tempest-admin-list__toolbar {
10968
+ display: flex;
10969
+ flex-wrap: wrap;
10970
+ gap: 0.75rem 1rem;
10971
+ align-items: flex-end;
10972
+ justify-content: space-between;
10973
+ margin-bottom: 1rem;
10974
+ }
10975
+
10976
+ .tempest-admin-list__actions {
10977
+ display: flex;
10978
+ gap: 0.5rem;
10979
+ align-items: center;
10980
+ }
10981
+
10982
+ .tempest-admin-list__actions a {
10983
+ display: inline-block;
10984
+ padding: 0.45rem 0.8rem;
10985
+ border: 1px solid var(--tempest-accent);
10986
+ border-radius: var(--tempest-radius);
10987
+ background: #fff;
10988
+ white-space: nowrap;
10989
+ }
10990
+
10991
+ .tempest-admin-list__actions a:hover {
10992
+ background: #eff6ff;
10993
+ text-decoration: none;
10994
+ }
10995
+
10996
+ /* Dashboard: system-metric stat cards + model cards. */
10997
+ .tempest-admin-stats {
10998
+ display: grid;
10999
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
11000
+ gap: 1rem;
11001
+ margin: 1.5rem 0;
11002
+ }
11003
+
11004
+ .tempest-admin-stat {
11005
+ display: flex;
11006
+ flex-direction: column;
11007
+ gap: 0.15rem;
11008
+ background: #fff;
11009
+ border-radius: var(--tempest-radius);
11010
+ padding: 1rem 1.25rem;
11011
+ box-shadow: var(--tempest-shadow);
11012
+ }
11013
+
11014
+ .tempest-admin-stat__label {
11015
+ font-size: 0.8rem;
11016
+ text-transform: uppercase;
11017
+ letter-spacing: 0.04em;
11018
+ color: var(--tempest-fg-soft);
11019
+ }
11020
+
11021
+ .tempest-admin-stat__value {
11022
+ font-size: 1.6rem;
11023
+ font-weight: 700;
11024
+ }
11025
+
11026
+ .tempest-admin-stat__sub {
11027
+ font-size: 0.8rem;
11028
+ color: var(--tempest-fg-soft);
11029
+ }
11030
+
11031
+ .tempest-admin-models {
11032
+ display: grid;
11033
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
11034
+ gap: 1rem;
11035
+ margin-top: 1rem;
11036
+ }
11037
+
11038
+ .tempest-admin-model-card {
11039
+ background: #fff;
11040
+ border-radius: var(--tempest-radius);
11041
+ padding: 1rem 1.25rem;
11042
+ box-shadow: var(--tempest-shadow);
11043
+ display: flex;
11044
+ flex-direction: column;
11045
+ gap: 0.75rem;
11046
+ }
11047
+
11048
+ .tempest-admin-model-card__head {
11049
+ display: flex;
11050
+ align-items: baseline;
11051
+ justify-content: space-between;
11052
+ gap: 0.5rem;
11053
+ }
11054
+
11055
+ .tempest-admin-model-card__head h2 {
11056
+ margin: 0;
11057
+ font-size: 1.05rem;
11058
+ }
11059
+
11060
+ .tempest-admin-model-card__count {
11061
+ font-weight: 700;
11062
+ color: var(--tempest-accent);
11063
+ }
11064
+
11065
+ .tempest-admin-model-card__actions {
11066
+ display: flex;
11067
+ gap: 1rem;
11068
+ }
11069
+
11070
+ /* Bulk-action bar above the list table. */
11071
+ .tempest-admin-bulk {
11072
+ margin: 0;
11073
+ }
11074
+
11075
+ .tempest-admin-bulk__bar {
11076
+ display: flex;
11077
+ gap: 0.5rem;
11078
+ align-items: center;
11079
+ margin-bottom: 0.75rem;
11080
+ }
11081
+
11082
+ .tempest-admin-list__check {
11083
+ width: 1%;
11084
+ white-space: nowrap;
11085
+ text-align: center;
11086
+ }
11087
+
11088
+ .tempest-admin-list__check input {
11089
+ width: auto;
11090
+ }
11091
+
11092
+ /* Horizontal-scroll wrapper so wide tables never break the layout. */
11093
+ .tempest-admin-table-wrap {
11094
+ width: 100%;
11095
+ overflow-x: auto;
11096
+ -webkit-overflow-scrolling: touch;
11097
+ border-radius: var(--tempest-radius);
11098
+ box-shadow: var(--tempest-shadow);
11099
+ }
11100
+
11101
+ .tempest-admin-table-wrap .tempest-admin-list__table,
11102
+ .tempest-admin-table-wrap .tempest-admin-dashboard__table {
11103
+ box-shadow: none;
11104
+ }
11105
+
11106
+ /* Sortable column headers. */
11107
+ .tempest-admin-list__table th a.tempest-sort {
11108
+ color: inherit;
11109
+ display: inline-flex;
11110
+ align-items: center;
11111
+ gap: 0.3rem;
11112
+ white-space: nowrap;
11113
+ }
11114
+
11115
+ .tempest-admin-list__table th a.tempest-sort:hover {
11116
+ color: var(--tempest-accent);
11117
+ text-decoration: none;
11118
+ }
11119
+
11120
+ .tempest-sort__arrow {
11121
+ font-size: 0.75em;
11122
+ color: var(--tempest-fg-soft);
11123
+ }
11124
+
11125
+ .tempest-sort--active .tempest-sort__arrow {
11126
+ color: var(--tempest-accent);
11127
+ }
11128
+
11129
+ /* Detail header actions (back / edit / delete). */
11130
+ .tempest-admin-detail__header {
11131
+ display: flex;
11132
+ flex-wrap: wrap;
11133
+ gap: 0.5rem 1rem;
11134
+ align-items: center;
11135
+ justify-content: space-between;
11136
+ margin-bottom: 1rem;
11137
+ }
11138
+
11139
+ .tempest-admin-detail__actions {
11140
+ display: flex;
11141
+ flex-wrap: wrap;
11142
+ gap: 0.5rem;
11143
+ align-items: center;
11144
+ }
11145
+
11146
+ .tempest-admin-detail__delete {
11147
+ margin: 0;
11148
+ }
11149
+
11150
+ .tempest-admin-btn {
11151
+ display: inline-block;
11152
+ padding: 0.45rem 0.8rem;
11153
+ border: 1px solid var(--tempest-accent);
11154
+ border-radius: var(--tempest-radius);
11155
+ background: #fff;
11156
+ }
11157
+
11158
+ .tempest-admin-btn:hover {
11159
+ background: #eff6ff;
11160
+ text-decoration: none;
11161
+ }
11162
+
11163
+ .tempest-admin-btn--danger {
11164
+ background: var(--tempest-danger);
11165
+ }
11166
+
11167
+ .tempest-admin-btn--danger:hover {
11168
+ background: #991b1b;
11169
+ }
11170
+
11171
+ /* Scoped under __actions so it beats \`.tempest-admin-list__actions a\`
11172
+ (otherwise the white action-link background won + white text = invisible). */
11173
+ .tempest-admin-list__actions a.tempest-admin-list__new,
11174
+ a.tempest-admin-list__new {
11175
+ background: var(--tempest-accent);
11176
+ color: #fff;
11177
+ border-color: var(--tempest-accent);
11178
+ }
11179
+
11180
+ .tempest-admin-list__actions a.tempest-admin-list__new:hover,
11181
+ a.tempest-admin-list__new:hover {
11182
+ background: var(--tempest-accent-hover);
11183
+ color: #fff;
11184
+ }
11185
+
11186
+ /* Create / edit form. */
11187
+ .tempest-admin-form__form {
11188
+ display: grid;
11189
+ gap: 1rem;
11190
+ max-width: 640px;
11191
+ background: #fff;
11192
+ padding: 1.5rem;
11193
+ border-radius: var(--tempest-radius);
11194
+ box-shadow: var(--tempest-shadow);
11195
+ }
11196
+
11197
+ .tempest-admin-form__field {
11198
+ display: flex;
11199
+ flex-direction: column;
11200
+ gap: 0.3rem;
11201
+ }
11202
+
11203
+ .tempest-admin-form__field > label {
11204
+ display: flex;
11205
+ flex-direction: column;
11206
+ gap: 0.25rem;
11207
+ }
11208
+
11209
+ .tempest-admin-form__field > label > span {
11210
+ font-weight: 600;
11211
+ color: var(--tempest-fg-soft);
11212
+ }
11213
+
11214
+ .tempest-admin-form__field input,
11215
+ .tempest-admin-form__field select,
11216
+ .tempest-admin-form__field textarea {
11217
+ width: 100%;
11218
+ }
11219
+
11220
+ .tempest-admin-form__json {
11221
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
11222
+ font-size: 0.85rem;
11223
+ white-space: pre;
11224
+ overflow-wrap: normal;
11225
+ overflow-x: auto;
11226
+ }
11227
+
11228
+ .tempest-admin-form__check {
11229
+ flex-direction: row !important;
11230
+ align-items: center;
11231
+ gap: 0.5rem;
11232
+ }
11233
+
11234
+ .tempest-admin-form__check input {
11235
+ width: auto;
11236
+ }
11237
+
11238
+ .tempest-admin-form__field--error input,
11239
+ .tempest-admin-form__field--error select,
11240
+ .tempest-admin-form__field--error textarea {
11241
+ border-color: var(--tempest-danger);
11242
+ }
11243
+
11244
+ .tempest-admin-form__field-error {
11245
+ color: var(--tempest-danger);
11246
+ }
11247
+
11248
+ .tempest-admin-form__error {
11249
+ background: #fef2f2;
11250
+ color: var(--tempest-danger);
11251
+ padding: 0.75rem 1rem;
11252
+ border-radius: var(--tempest-radius);
11253
+ margin-bottom: 1rem;
11254
+ }
11255
+
11256
+ .tempest-admin-form__actions {
11257
+ display: flex;
11258
+ gap: 1rem;
11259
+ align-items: center;
11260
+ }
11261
+
11262
+ /* ---- Responsive ---- */
11263
+
11264
+ /* Tablet and below. */
11265
+ @media (max-width: 1023px) {
11266
+ .tempest-admin-main {
11267
+ margin: 1.25rem auto;
11268
+ padding: 0 1rem;
11269
+ }
11270
+ }
11271
+
11272
+ /* Mobile (<= 430px and small phones). */
11273
+ @media (max-width: 600px) {
11274
+ .tempest-admin-header {
11275
+ flex-direction: column;
11276
+ align-items: flex-start;
11277
+ gap: 0.5rem;
11278
+ padding: 0.75rem 1rem;
11279
+ }
11280
+
11281
+ .tempest-admin-header__nav {
11282
+ width: 100%;
11283
+ flex-wrap: wrap;
11284
+ gap: 0.5rem 0.75rem;
11285
+ }
11286
+
11287
+ .tempest-admin-main {
11288
+ margin: 1rem auto;
11289
+ padding: 0 0.75rem;
11290
+ }
11291
+
11292
+ .tempest-admin-list__header {
11293
+ flex-direction: column;
11294
+ gap: 0.25rem;
11295
+ align-items: flex-start;
11296
+ }
11297
+
11298
+ .tempest-admin-list__toolbar {
11299
+ flex-direction: column;
11300
+ align-items: stretch;
11301
+ }
11302
+
11303
+ .tempest-admin-list__filters {
11304
+ flex-direction: column;
11305
+ align-items: stretch;
11306
+ }
11307
+
11308
+ .tempest-admin-list__filters input,
11309
+ .tempest-admin-list__filters select,
11310
+ .tempest-admin-list__filters button {
11311
+ width: 100%;
11312
+ }
11313
+
11314
+ .tempest-admin-list__actions {
11315
+ justify-content: stretch;
11316
+ }
11317
+
11318
+ .tempest-admin-list__actions a {
11319
+ flex: 1;
11320
+ text-align: center;
11321
+ }
11322
+
11323
+ /* Stack the detail grid into single-column rows. */
11324
+ .tempest-admin-detail__fields {
11325
+ grid-template-columns: 1fr;
11326
+ gap: 0.15rem 0;
11327
+ padding: 1rem;
11328
+ }
11329
+
11330
+ /* The logs table becomes stacked cards. Horizontal scroll is fine for a
11331
+ list view you skim, but the logs table's fourth column carries the
11332
+ message, the request context and the traceback \u2014 everything you came to
11333
+ read \u2014 so at this width it sat off-screen behind a sideways drag, and an
11334
+ opened traceback showed up only as a tall blank row. Labels come from
11335
+ each cell's data-label, so the header can be dropped without losing
11336
+ which value is which. Scoped to this table: the other list views keep
11337
+ the scroll treatment. */
11338
+ .tempest-admin-logs .tempest-admin-table-wrap {
11339
+ overflow-x: visible;
11340
+ }
11341
+
11342
+ .tempest-admin-logs__table thead {
11343
+ display: none;
11344
+ }
11345
+
11346
+ .tempest-admin-logs__table,
11347
+ .tempest-admin-logs__table tbody,
11348
+ .tempest-admin-logs__table tr,
11349
+ .tempest-admin-logs__table td {
11350
+ display: block;
11351
+ width: 100%;
11352
+ }
11353
+
11354
+ .tempest-admin-logs__table tr {
11355
+ padding: 0.85rem 0.9rem;
11356
+ border-bottom: 1px solid #e2e8f0;
11357
+ }
11358
+
11359
+ .tempest-admin-logs__table tr:last-child {
11360
+ border-bottom: none;
11361
+ }
11362
+
11363
+ .tempest-admin-logs__table td {
11364
+ padding: 0.1rem 0;
11365
+ border: none;
11366
+ }
11367
+
11368
+ .tempest-admin-logs__table td[data-label]::before {
11369
+ content: attr(data-label);
11370
+ display: block;
11371
+ color: var(--tempest-fg-soft);
11372
+ text-transform: uppercase;
11373
+ letter-spacing: 0.04em;
11374
+ font-size: 0.64rem;
11375
+ font-weight: 600;
11376
+ }
11377
+
11378
+ /* The message is the card's body, not another labelled field. */
11379
+ .tempest-admin-logs__table td.tempest-admin-logs__msg {
11380
+ margin-top: 0.5rem;
11381
+ }
11382
+
11383
+ .tempest-admin-logs__table td.tempest-admin-logs__msg::before {
11384
+ display: none;
11385
+ }
11386
+
11387
+ .tempest-admin-logs__logger {
11388
+ white-space: normal;
11389
+ overflow-wrap: anywhere;
11390
+ }
11391
+
11392
+ .tempest-admin-detail__fields dt {
11393
+ margin-top: 0.75rem;
11394
+ }
11395
+
11396
+ .tempest-admin-detail__fields dd {
11397
+ padding-bottom: 0.5rem;
11398
+ border-bottom: 1px solid #e2e8f0;
11399
+ }
11400
+
11401
+ .tempest-admin-login {
11402
+ margin: 1.5rem auto;
11403
+ padding: 1.5rem;
11404
+ }
11405
+
11406
+ .tempest-admin-form__form {
11407
+ padding: 1rem;
11408
+ }
11409
+
11410
+ .tempest-admin-form__actions {
11411
+ flex-direction: column-reverse;
11412
+ align-items: stretch;
11413
+ }
11414
+
11415
+ .tempest-admin-form__actions button,
11416
+ .tempest-admin-form__actions .tempest-admin-form__cancel {
11417
+ width: 100%;
11418
+ text-align: center;
11419
+ }
11420
+
11421
+ .tempest-admin-bulk__bar {
11422
+ flex-direction: column;
11423
+ align-items: stretch;
11424
+ }
11425
+
11426
+ .tempest-admin-bulk__bar select,
11427
+ .tempest-admin-bulk__bar button {
11428
+ width: 100%;
11429
+ }
11430
+ }
11431
+
11432
+ /* ---- Application logs page ---- */
11433
+ .tempest-log-badge {
11434
+ display: inline-block;
11435
+ padding: 0.1rem 0.5rem;
11436
+ border-radius: 999px;
11437
+ font-size: 0.72rem;
11438
+ font-weight: 600;
11439
+ text-transform: uppercase;
11440
+ letter-spacing: 0.03em;
11441
+ background: #e2e8f0;
11442
+ color: #334155;
11443
+ }
11444
+
11445
+ .tempest-log-badge--debug {
11446
+ background: #e2e8f0;
11447
+ color: #475569;
11448
+ }
11449
+
11450
+ .tempest-log-badge--info {
11451
+ background: #dbeafe;
11452
+ color: #1e40af;
11453
+ }
11454
+
11455
+ .tempest-log-badge--warning {
11456
+ background: #fef3c7;
11457
+ color: #92400e;
11458
+ }
11459
+
11460
+ .tempest-log-badge--error {
11461
+ background: #fee2e2;
11462
+ color: #991b1b;
11463
+ }
11464
+
11465
+ .tempest-log-badge--critical {
11466
+ background: #7f1d1d;
11467
+ color: #fff;
11468
+ }
11469
+
11470
+ .tempest-admin-logs__ts {
11471
+ white-space: nowrap;
11472
+ font-variant-numeric: tabular-nums;
11473
+ color: var(--tempest-fg-soft);
11474
+ }
11475
+
11476
+ .tempest-admin-logs__logger {
11477
+ color: var(--tempest-fg-soft);
11478
+ white-space: nowrap;
11479
+ }
11480
+
11481
+ .tempest-admin-logs__msg {
11482
+ word-break: break-word;
11483
+ }
11484
+
11485
+ .tempest-admin-logs__empty {
11486
+ color: var(--tempest-fg-soft);
11487
+ background: #fff;
11488
+ padding: 1.5rem;
11489
+ border-radius: var(--tempest-radius);
11490
+ box-shadow: var(--tempest-shadow);
11491
+ }
11492
+
11493
+ .tempest-admin-logs__actions {
11494
+ display: flex;
11495
+ gap: 0.5rem;
11496
+ flex-wrap: wrap;
11497
+ }
11498
+
11499
+ .tempest-admin-logs__export {
11500
+ display: inline-block;
11501
+ padding: 0.35rem 0.75rem;
11502
+ border-radius: var(--tempest-radius);
11503
+ border: 1px solid #cbd5e1;
11504
+ background: #fff;
11505
+ color: #334155;
11506
+ font-size: 0.82rem;
11507
+ font-weight: 600;
11508
+ text-decoration: none;
11509
+ }
11510
+
11511
+ .tempest-admin-logs__export:hover {
11512
+ background: #f1f5f9;
11513
+ }
11514
+
11515
+ .tempest-admin-logs__meta {
11516
+ list-style: none;
11517
+ margin: 0.4rem 0 0;
11518
+ padding: 0;
11519
+ display: flex;
11520
+ flex-wrap: wrap;
11521
+ gap: 0.3rem 0.75rem;
11522
+ font-size: 0.75rem;
11523
+ }
11524
+
11525
+ /* The gap is layout only \u2014 it does not survive a copy, and an operator copying a
11526
+ row to paste into an issue would get "status_code404". The template keeps a
11527
+ real space between the label and the value for that; this widens the visual
11528
+ separation on top of it, since an uppercase micro-label sitting against a
11529
+ monospace value reads as one token. */
11530
+ .tempest-admin-logs__meta li {
11531
+ display: inline-flex;
11532
+ align-items: baseline;
11533
+ gap: 0.4rem;
11534
+ }
11535
+
11536
+ .tempest-admin-logs__meta span {
11537
+ color: var(--tempest-fg-soft);
11538
+ text-transform: uppercase;
11539
+ letter-spacing: 0.03em;
11540
+ font-size: 0.68rem;
11541
+ }
11542
+
11543
+ .tempest-admin-logs__meta code {
11544
+ word-break: break-all;
11545
+ }
11546
+
11547
+ /* Collapsed by default so a page full of 500s stays scannable; details/summary
11548
+ keeps that working with no JavaScript, which the admin does not ship. */
11549
+ .tempest-admin-logs__trace {
11550
+ margin-top: 0.5rem;
11551
+ }
11552
+
11553
+ /* The summary carries the record's own message, so the whole item is the click
11554
+ target instead of a separate link. It must therefore read as body text, not
11555
+ as a control \u2014 the affordance is the marker plus the hint chip. */
11556
+ .tempest-admin-logs__trace summary {
11557
+ cursor: pointer;
11558
+ list-style: none;
11559
+ display: block;
11560
+ }
11561
+
11562
+ .tempest-admin-logs__trace summary::-webkit-details-marker {
11563
+ display: none;
11564
+ }
11565
+
11566
+ .tempest-admin-logs__msg-text::before {
11567
+ content: "\u25B8";
11568
+ display: inline-block;
11569
+ width: 0.9rem;
11570
+ color: #991b1b;
11571
+ font-size: 0.7rem;
11572
+ }
11573
+
11574
+ .tempest-admin-logs__trace[open] .tempest-admin-logs__msg-text::before {
11575
+ content: "\u25BE";
11576
+ }
11577
+
11578
+ .tempest-admin-logs__trace-hint {
11579
+ display: inline-block;
11580
+ margin-top: 0.3rem;
11581
+ font-size: 0.68rem;
11582
+ font-weight: 600;
11583
+ text-transform: uppercase;
11584
+ letter-spacing: 0.04em;
11585
+ color: #991b1b;
11586
+ }
11587
+
11588
+ .tempest-admin-logs__trace[open] .tempest-admin-logs__trace-hint {
11589
+ opacity: 0.55;
11590
+ }
11591
+
11592
+ /* Lines wrap instead of extending. A <pre> is sized by its longest line and it
11593
+ sits in a table cell, which sizes to content, so \`overflow-x\` on the <pre>
11594
+ cannot shrink it: the table grew past its scroll container (measured 1364px
11595
+ against a 1012px wrap) and the trace could only be read by dragging the table
11596
+ sideways. Wrapping keeps the table at its container width. It costs the
11597
+ alignment of the caret markers on screen; the markdown export carries the
11598
+ exact unwrapped text for real analysis. */
11599
+ .tempest-admin-logs__trace pre {
11600
+ margin: 0.5rem 0 0;
11601
+ padding: 0.75rem;
11602
+ max-height: 24rem;
11603
+ overflow-y: auto;
11604
+ background: #0f172a;
11605
+ color: #e2e8f0;
11606
+ border-radius: var(--tempest-radius);
11607
+ font-size: 0.74rem;
11608
+ line-height: 1.45;
11609
+ white-space: pre-wrap;
11610
+ overflow-wrap: anywhere;
11611
+ -webkit-user-select: all;
11612
+ user-select: all;
11613
+ }
11614
+
11615
+ .tempest-admin-logs__note {
11616
+ color: var(--tempest-fg-soft);
11617
+ font-size: 0.8rem;
11618
+ margin-top: 0.75rem;
11619
+ }
11620
+
11621
+ /* ---- Sidebar off-canvas on tablet/mobile, burger reveals it ---- */
11622
+ @media (max-width: 768px) {
11623
+ .tempest-admin-burger {
11624
+ display: flex;
11625
+ }
11626
+
11627
+ .tempest-admin-sidebar {
11628
+ position: fixed;
11629
+ top: 0;
11630
+ left: 0;
11631
+ bottom: 0;
11632
+ z-index: 50;
11633
+ width: 240px;
11634
+ flex-basis: 240px;
11635
+ transform: translateX(-100%);
11636
+ transition: transform 0.2s ease;
11637
+ overflow-y: auto;
11638
+ box-shadow: 0 0 24px rgba(15, 23, 42, 0.35);
11639
+ }
11640
+
11641
+ .tempest-admin-navtoggle:checked ~ .tempest-admin-layout .tempest-admin-sidebar {
11642
+ transform: translateX(0);
11643
+ }
11644
+
11645
+ .tempest-admin-navtoggle:checked ~ .tempest-admin-layout .tempest-admin-scrim {
11646
+ display: block;
11647
+ position: fixed;
11648
+ inset: 0;
11649
+ z-index: 40;
11650
+ background: rgba(15, 23, 42, 0.45);
11651
+ }
11652
+ }
11653
+
11654
+ /* ---- Desktop: sidebar overlays the header + footer ---- */
11655
+ @media (min-width: 769px) {
11656
+ .tempest-admin-sidebar {
11657
+ position: fixed;
11658
+ top: 0;
11659
+ left: 0;
11660
+ bottom: 0;
11661
+ z-index: 100; /* above header/footer (z-index: 20) */
11662
+ overflow-y: auto;
11663
+ /* Clear the header bar so the first nav link sits below it. */
11664
+ padding-top: 3.75rem;
11665
+ }
11666
+
11667
+ /* The fixed sidebar leaves the flex flow, so reserve its gutter on
11668
+ the layout row to keep the main content from sliding underneath. */
11669
+ .tempest-admin-layout {
11670
+ padding-left: 220px;
11671
+ }
11672
+ }
11673
+
11674
+ /* Flash banner shown after a custom bulk action runs. */
11675
+ .tempest-admin-flash {
11676
+ margin: 0 0 1rem;
11677
+ padding: 0.7rem 1rem;
11678
+ border-radius: var(--tempest-radius);
11679
+ border-left: 4px solid var(--tempest-accent);
11680
+ background: var(--tempest-bg);
11681
+ color: var(--tempest-fg);
11682
+ }
11683
+
11684
+ .tempest-admin-flash--error {
11685
+ border-left-color: var(--tempest-danger);
11686
+ }
11687
+
11688
+ .tempest-admin-flash--warning {
11689
+ border-left-color: #d97706;
11690
+ }
11691
+
11692
+ /* Task panel \u2014 declared schedule and persisted runs. */
11693
+ .tempest-admin-tasks__scope {
11694
+ margin: 0 0 1.25rem;
11695
+ color: var(--tempest-fg-soft);
11696
+ font-size: 0.9rem;
11697
+ }
11698
+
11699
+ .tempest-admin-tasks__heading {
11700
+ margin: 1.75rem 0 0.75rem;
11701
+ font-size: 1.05rem;
11702
+ }
11703
+
11704
+ .tempest-admin-tasks__empty {
11705
+ padding: 1rem;
11706
+ border: 1px dashed var(--tempest-border);
11707
+ border-radius: var(--tempest-radius);
11708
+ color: var(--tempest-fg-soft);
11709
+ }
11710
+
11711
+ .tempest-admin-tasks__trigger + .tempest-admin-tasks__trigger {
11712
+ margin-top: 0.35rem;
11713
+ }
11714
+
11715
+ .tempest-admin-tasks__offset {
11716
+ color: var(--tempest-fg-soft);
11717
+ font-size: 0.85rem;
11718
+ white-space: nowrap;
11719
+ }
11720
+
11721
+ .tempest-admin-tasks__status {
11722
+ display: inline-block;
11723
+ padding: 0.1rem 0.5rem;
11724
+ border-radius: 999px;
11725
+ font-size: 0.8rem;
11726
+ border: 1px solid var(--tempest-border);
11727
+ color: var(--tempest-fg-soft);
11728
+ }
11729
+
11730
+ .tempest-admin-tasks__status--running {
11731
+ border-color: var(--tempest-accent);
11732
+ color: var(--tempest-accent);
11733
+ }
11734
+
11735
+ .tempest-admin-tasks__status--done {
11736
+ border-color: #15803d;
11737
+ color: #15803d;
11738
+ }
11739
+
11740
+ .tempest-admin-tasks__status--failed {
11741
+ border-color: var(--tempest-danger);
11742
+ color: var(--tempest-danger);
11743
+ }
11744
+
11745
+ /* Cancelled is terminal but not a failure, so it stays neutral: an
11746
+ operator scanning for red should find only what went wrong. */
11747
+ .tempest-admin-tasks__status--cancelled {
11748
+ border-color: var(--tempest-border);
11749
+ color: var(--tempest-fg-soft);
11750
+ }
11751
+
11752
+ /* The track uses the border token, not a row background: the list stripes
11753
+ rows with --tempest-bg-row-alt, so a track painted in it vanished on
11754
+ every other row \u2014 an empty progress cell reads as "no data", not as 0%. */
11755
+ .tempest-admin-tasks__bar {
11756
+ display: inline-block;
11757
+ width: 90px;
11758
+ height: 8px;
11759
+ border-radius: 999px;
11760
+ background: var(--tempest-border);
11761
+ overflow: hidden;
11762
+ vertical-align: middle;
11763
+ }
11764
+
11765
+ .tempest-admin-tasks__bar--wide {
11766
+ display: block;
11767
+ width: 100%;
11768
+ height: 12px;
11769
+ margin: 0 0 1.25rem;
11770
+ }
11771
+
11772
+ .tempest-admin-tasks__bar-fill {
11773
+ display: block;
11774
+ height: 100%;
11775
+ background: var(--tempest-accent);
11776
+ }
11777
+
11778
+ .tempest-admin-tasks__facts {
11779
+ display: grid;
11780
+ grid-template-columns: max-content 1fr;
11781
+ gap: 0.4rem 1.25rem;
11782
+ margin: 0 0 1.5rem;
11783
+ }
11784
+
11785
+ .tempest-admin-tasks__facts dt {
11786
+ color: var(--tempest-fg-soft);
11787
+ font-size: 0.85rem;
11788
+ }
11789
+
11790
+ .tempest-admin-tasks__facts dd {
11791
+ margin: 0;
11792
+ }
11793
+
11794
+ .tempest-admin-tasks__error pre {
11795
+ padding: 0.85rem;
11796
+ border-radius: var(--tempest-radius);
11797
+ border-left: 4px solid var(--tempest-danger);
11798
+ background: var(--tempest-bg-row-alt);
11799
+ overflow-x: auto;
11800
+ white-space: pre-wrap;
11801
+ }
11802
+
11803
+ .tempest-admin-tasks__cancel {
11804
+ display: flex;
11805
+ align-items: center;
11806
+ gap: 0.75rem;
11807
+ flex-wrap: wrap;
11808
+ margin: 0 0 1.5rem;
11809
+ }
11810
+
11811
+ .tempest-admin-tasks__hint {
11812
+ color: var(--tempest-fg-soft);
11813
+ font-size: 0.85rem;
11814
+ }
11815
+
11816
+ @media (max-width: 640px) {
11817
+ .tempest-admin-tasks__facts {
11818
+ grid-template-columns: 1fr;
11819
+ gap: 0.15rem;
11820
+ }
11821
+
11822
+ .tempest-admin-tasks__facts dd {
11823
+ margin: 0 0 0.6rem;
11824
+ }
11825
+ }
11826
+ `;
11827
+
11828
+ // src/admin/theme.ts
11829
+ var FORBIDDEN_CHARS = ["<", ">", "{", "}", '"'];
11830
+ function assertSafe(field, value) {
11831
+ if (value === void 0) return;
11832
+ const bad = FORBIDDEN_CHARS.filter((char) => value.includes(char));
11833
+ if (bad.length > 0) {
11834
+ throw new Error(
11835
+ `AdminTheme.${field} contains forbidden character(s) ${bad.join(" ")}; these would break the injected <style>/HTML. Got: ${value}`
11836
+ );
11837
+ }
11838
+ }
11839
+ function resolveAdminTheme(theme = {}) {
11840
+ for (const [field, value] of Object.entries(theme)) {
11841
+ if (typeof value === "string") assertSafe(field, value);
11842
+ }
11843
+ const headerBg = theme.headerBg ?? "#0f172a";
11844
+ return {
11845
+ accent: theme.accent ?? "#2563eb",
11846
+ accentHover: theme.accentHover ?? "#1d4ed8",
11847
+ danger: theme.danger ?? "#b91c1c",
11848
+ headerBg,
11849
+ sidebarBg: theme.sidebarBg ?? headerBg,
11850
+ pageBg: theme.pageBg ?? null,
11851
+ radius: theme.radius ?? "6px",
11852
+ fontFamily: theme.fontFamily ?? null,
11853
+ logoUrl: theme.logoUrl ?? null,
11854
+ logoAlt: theme.logoAlt ?? "Logo",
11855
+ faviconUrl: theme.faviconUrl ?? null,
11856
+ footerText: theme.footerText ?? "Powered by tempest-express-sdk",
11857
+ darkMode: theme.darkMode ?? false,
11858
+ customCssUrl: theme.customCssUrl ?? null
11859
+ };
11860
+ }
11861
+ function adminThemeCss(theme) {
11862
+ const variables = {
11863
+ "--tempest-accent": theme.accent,
11864
+ "--tempest-accent-hover": theme.accentHover,
11865
+ "--tempest-danger": theme.danger,
11866
+ "--tempest-bg": theme.headerBg,
11867
+ "--tempest-bg-soft": theme.sidebarBg,
11868
+ "--tempest-radius": theme.radius
11869
+ };
11870
+ if (theme.fontFamily !== null) variables["--tempest-font"] = theme.fontFamily;
11871
+ if (theme.pageBg !== null) variables["--tempest-page-bg"] = theme.pageBg;
11872
+ const rootLines = Object.entries(variables).map(([name, value]) => ` ${name}: ${value};`).join("\n");
11873
+ const blocks = [`:root {
11874
+ ${rootLines}
11875
+ }`];
11876
+ if (theme.darkMode && theme.pageBg === null) {
11877
+ blocks.push(
11878
+ [
11879
+ ":root {",
11880
+ " --tempest-page-bg: #0b1120;",
11881
+ " --tempest-bg-row: #1e293b;",
11882
+ " --tempest-bg-row-alt: #172033;",
11883
+ " --tempest-fg: #e2e8f0;",
11884
+ " --tempest-fg-soft: #94a3b8;",
11885
+ "}",
11886
+ "body { color: var(--tempest-fg); }",
11887
+ "input, select, textarea {",
11888
+ " background: #1e293b;",
11889
+ " color: var(--tempest-fg);",
11890
+ " border-color: #334155;",
11891
+ "}"
11892
+ ].join("\n")
11893
+ );
11894
+ }
11895
+ if (theme.fontFamily !== null) {
11896
+ blocks.push("body { font-family: var(--tempest-font); }");
11897
+ }
11898
+ return blocks.join("\n");
11899
+ }
11900
+
11901
+ // src/admin/templates.ts
11902
+ function escapeHtml(value) {
11903
+ return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
11904
+ }
11905
+ function renderLayout(context, title, body) {
11906
+ const { site, theme, prefix, session, currentPath, navModels, messages } = context;
11907
+ const authed = session !== null;
11908
+ const indexUrl = `${prefix}/`;
11909
+ const navLink = (url, label, active) => `<a href="${escapeHtml(url)}" class="tempest-admin-sidebar__link${active ? " tempest-admin-sidebar__link--active" : ""}">${escapeHtml(label)}</a>`;
11910
+ const sidebar = authed ? `<label for="tempest-nav-toggle" class="tempest-admin-scrim" aria-hidden="true"></label>
11911
+ <aside class="tempest-admin-sidebar">
11912
+ <nav class="tempest-admin-sidebar__nav" aria-label="Admin navigation">
11913
+ ${navLink(indexUrl, "Dashboard", currentPath === indexUrl)}
11914
+ ${navModels.length > 0 ? `<span class="tempest-admin-sidebar__heading">Models</span>${navModels.map(
11915
+ (entry) => navLink(entry.url, entry.label, currentPath.startsWith(entry.url))
11916
+ ).join("")}` : ""}
11917
+ </nav>
11918
+ </aside>` : "";
11919
+ const brand = theme.logoUrl !== null ? `<img src="${escapeHtml(theme.logoUrl)}" alt="${escapeHtml(theme.logoAlt)}" class="tempest-admin-header__logo">` : escapeHtml(site.brandText());
11920
+ const headerNav = authed ? `<nav class="tempest-admin-header__nav">
11921
+ <span class="tempest-admin-header__user">${escapeHtml(session.displayName)}</span>
11922
+ ${site.siteUrl !== null ? `<a href="${escapeHtml(site.siteUrl)}">View site</a>` : ""}
11923
+ <form method="post" action="${escapeHtml(`${prefix}/logout`)}" class="tempest-admin-header__logout">
11924
+ <input type="hidden" name="csrf_token" value="${escapeHtml(session.csrfToken)}">
11925
+ <button type="submit">Logout</button>
11926
+ </form>
11927
+ </nav>` : "";
11928
+ const banners = messages.length > 0 ? `<ul class="tempest-admin-messages">${messages.map(
11929
+ (message) => `<li class="tempest-admin-messages__item tempest-admin-messages__item--${escapeHtml(
11930
+ message.level
11931
+ )}">${escapeHtml(message.text)}</li>`
11932
+ ).join("")}</ul>` : "";
11933
+ return `<!doctype html>
11934
+ <html lang="en">
11935
+ <head>
11936
+ <meta charset="utf-8">
11937
+ <meta name="viewport" content="width=device-width, initial-scale=1">
11938
+ <title>${escapeHtml(title)}</title>
11939
+ ${theme.faviconUrl !== null ? `<link rel="icon" href="${escapeHtml(theme.faviconUrl)}">` : ""}
11940
+ <link rel="stylesheet" href="${escapeHtml(`${prefix}/static/admin.css`)}">
11941
+ <style>${adminThemeCss(theme)}</style>
11942
+ ${theme.customCssUrl !== null ? `<link rel="stylesheet" href="${escapeHtml(theme.customCssUrl)}">` : ""}
11943
+ </head>
11944
+ <body>
11945
+ ${authed ? '<input type="checkbox" id="tempest-nav-toggle" class="tempest-admin-navtoggle" hidden>' : ""}
11946
+ <header class="tempest-admin-header">
11947
+ <div class="tempest-admin-header__left">
11948
+ ${authed ? `<label for="tempest-nav-toggle" class="tempest-admin-burger" aria-label="Toggle navigation"><span></span><span></span><span></span></label>` : ""}
11949
+ <div class="tempest-admin-header__brand"><a href="${escapeHtml(indexUrl)}">${brand}</a></div>
11950
+ </div>
11951
+ ${headerNav}
11952
+ </header>
11953
+ <div class="tempest-admin-layout">
11954
+ ${sidebar}
11955
+ <main class="tempest-admin-main">
11956
+ ${banners}
11957
+ ${body}
11958
+ </main>
11959
+ </div>
11960
+ <footer class="tempest-admin-footer"><small>${escapeHtml(theme.footerText)}</small></footer>
11961
+ </body>
11962
+ </html>`;
11963
+ }
11964
+ function renderLoginPage(context, error) {
11965
+ const body = `<section class="tempest-admin-login">
11966
+ <h1>Sign in</h1>
11967
+ <p>${escapeHtml(context.site.indexSubtitle)}</p>
11968
+ ${error !== null ? `<p class="tempest-admin-login__error" role="alert">${escapeHtml(error)}</p>` : ""}
11969
+ <form method="post" action="${escapeHtml(`${context.prefix}/login`)}" class="tempest-admin-login__form">
11970
+ <label><span>Email</span><input type="email" name="identifier" autocomplete="username" required autofocus></label>
11971
+ <label><span>Password</span><input type="password" name="password" autocomplete="current-password" required></label>
11972
+ <button type="submit">Sign in</button>
11973
+ </form>
11974
+ </section>`;
11975
+ return renderLayout(context, `Sign in \xB7 ${context.site.title}`, body);
11976
+ }
11977
+ function renderMfaPage(context, error) {
11978
+ const body = `<section class="tempest-admin-login">
11979
+ <h1>Two-factor code</h1>
11980
+ <p>Enter the 6-digit code from your authenticator app.</p>
11981
+ ${error !== null ? `<p class="tempest-admin-login__error" role="alert">${escapeHtml(error)}</p>` : ""}
11982
+ <form method="post" action="${escapeHtml(`${context.prefix}/mfa`)}" class="tempest-admin-login__form">
11983
+ <label><span>Code</span><input type="text" name="code" inputmode="numeric" autocomplete="one-time-code" required autofocus></label>
11984
+ <button type="submit">Verify</button>
11985
+ </form>
11986
+ </section>`;
11987
+ return renderLayout(context, `Two-factor \xB7 ${context.site.title}`, body);
11988
+ }
11989
+ function renderDashboardPage(context, cards, metrics) {
11990
+ const metricsPanel = metrics === null ? "" : `<div class="tempest-admin-stats" aria-label="System metrics">
11991
+ <div class="tempest-admin-stat">
11992
+ <span class="tempest-admin-stat__label">CPU</span>
11993
+ <span class="tempest-admin-stat__value">${escapeHtml(metrics.cpuPercent)}%</span>
11994
+ </div>
11995
+ <div class="tempest-admin-stat">
11996
+ <span class="tempest-admin-stat__label">Memory</span>
11997
+ <span class="tempest-admin-stat__value">${escapeHtml(metrics.memoryPercent)}%</span>
11998
+ <span class="tempest-admin-stat__sub">${escapeHtml(metrics.memoryUsedGb)} / ${escapeHtml(metrics.memoryTotalGb)} GB</span>
11999
+ </div>
12000
+ </div>`;
12001
+ const models = cards.length > 0 ? `<div class="tempest-admin-models">${cards.map(
12002
+ (card) => `<article class="tempest-admin-model-card">
12003
+ <header class="tempest-admin-model-card__head">
12004
+ <h2>${escapeHtml(card.label)}</h2>
12005
+ ${card.count !== null ? `<span class="tempest-admin-model-card__count">${escapeHtml(card.count)}</span>` : ""}
12006
+ </header>
12007
+ <div class="tempest-admin-model-card__actions">
12008
+ <a href="${escapeHtml(card.url)}">Browse</a>
12009
+ ${card.newUrl !== null ? `<a href="${escapeHtml(card.newUrl)}">+ New</a>` : ""}
12010
+ </div>
12011
+ </article>`
12012
+ ).join("")}</div>` : "<p>No models registered. Register an <code>AdminModel</code> on the <code>AdminSite</code> to populate this dashboard.</p>";
12013
+ const body = `<section class="tempest-admin-dashboard">
12014
+ <h1>${escapeHtml(context.site.title)}</h1>
12015
+ <p>${escapeHtml(context.site.indexSubtitle)}</p>
12016
+ ${metricsPanel}
12017
+ ${models}
12018
+ </section>`;
12019
+ return renderLayout(context, context.site.title, body);
12020
+ }
12021
+ function renderFilter(filter) {
12022
+ const name = `filter_${filter.field}`;
12023
+ if (filter.kind === "select") {
12024
+ const options = filter.options.map(
12025
+ (option) => `<option value="${escapeHtml(option.value)}"${option.selected ? " selected" : ""}>${escapeHtml(option.label)}</option>`
12026
+ ).join("");
12027
+ return `<label><span>${escapeHtml(filter.label)}</span><select name="${escapeHtml(name)}"><option value="">\u2014 any \u2014</option>${options}</select></label>`;
12028
+ }
12029
+ if (filter.kind === "daterange") {
12030
+ return `<label><span>${escapeHtml(filter.label)}</span><span class="tempest-admin-list__daterange">
12031
+ <input type="date" name="${escapeHtml(`${name}_from`)}" value="${escapeHtml(filter.valueFrom)}" aria-label="${escapeHtml(filter.label)} from">
12032
+ <input type="date" name="${escapeHtml(`${name}_to`)}" value="${escapeHtml(filter.valueTo)}" aria-label="${escapeHtml(filter.label)} to">
12033
+ </span></label>`;
12034
+ }
12035
+ return `<label><span>${escapeHtml(filter.label)}</span><input type="text" name="${escapeHtml(name)}" value="${escapeHtml(filter.value)}"></label>`;
12036
+ }
12037
+ function renderListPage(context, view) {
12038
+ const bulk = view.bulkActions.length > 0 && context.session !== null;
12039
+ const checkColumn = bulk ? 1 : 0;
12040
+ const headers = view.columns.map((column6) => {
12041
+ const state = view.sort[column6];
12042
+ if (state === void 0) return `<th>${escapeHtml(column6)}</th>`;
12043
+ const arrow = state.active ? state.ascending ? "\u25B2" : "\u25BC" : "\u2195";
12044
+ return `<th><a class="tempest-sort${state.active ? " tempest-sort--active" : ""}" href="${escapeHtml(state.url)}"><span>${escapeHtml(column6)}</span><span class="tempest-sort__arrow" aria-hidden="true">${arrow}</span></a></th>`;
12045
+ }).join("");
12046
+ const rows = view.rows.length > 0 ? view.rows.map((row) => {
12047
+ const check = bulk ? `<td class="tempest-admin-list__check"><input type="checkbox" name="ids" value="${escapeHtml(row.identity)}" data-row-check aria-label="Select row"></td>` : "";
12048
+ const cells = row.cells.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("");
12049
+ return `<tr>${check}${cells}<td><a href="${escapeHtml(row.url)}">View</a></td></tr>`;
12050
+ }).join("") : `<tr><td colspan="${view.columns.length + 1 + checkColumn}">No records.</td></tr>`;
12051
+ const bulkBar = bulk ? `<form method="post" action="${escapeHtml(view.bulkUrl)}" class="tempest-admin-bulk" onsubmit="return confirm('Apply the selected action to the checked rows?');">
12052
+ <input type="hidden" name="csrf_token" value="${escapeHtml(context.session?.csrfToken)}">
12053
+ <div class="tempest-admin-bulk__bar">
12054
+ <select name="action" aria-label="Bulk action">
12055
+ ${view.bulkActions.map(
12056
+ (action) => `<option value="${escapeHtml(action.value)}">${escapeHtml(action.label)}${action.dangerous ? " \u26A0" : ""}</option>`
12057
+ ).join("")}
12058
+ </select>
12059
+ <button type="submit">Apply to selected</button>
12060
+ </div>` : "";
12061
+ const selectAllScript = bulk ? `<script>
12062
+ (function () {
12063
+ var master = document.querySelector('[data-select-all]');
12064
+ if (!master) return;
12065
+ master.addEventListener('change', function () {
12066
+ document.querySelectorAll('[data-row-check]').forEach(function (box) {
12067
+ box.checked = master.checked;
12068
+ });
12069
+ });
12070
+ })();
12071
+ </script>` : "";
12072
+ const hasControls = view.searchable || view.filters.length > 0;
12073
+ const body = `<section class="tempest-admin-list">
12074
+ <header class="tempest-admin-list__header">
12075
+ <h1>${escapeHtml(view.title)}</h1>
12076
+ <p>${escapeHtml(view.total)} record${view.total === 1 ? "" : "s"}.</p>
12077
+ </header>
12078
+ <div class="tempest-admin-list__toolbar">
12079
+ <form method="get" class="tempest-admin-list__filters">
12080
+ ${view.searchable ? `<input type="search" name="q" value="${escapeHtml(view.searchValue)}" placeholder="Search\u2026" aria-label="Search">` : ""}
12081
+ ${view.filters.map(renderFilter).join("")}
12082
+ ${hasControls ? '<button type="submit">Apply</button>' : ""}
12083
+ </form>
12084
+ <div class="tempest-admin-list__actions">
12085
+ ${view.newUrl !== null ? `<a class="tempest-admin-list__new" href="${escapeHtml(view.newUrl)}">+ New</a>` : ""}
12086
+ <a href="${escapeHtml(view.exportCsvUrl)}">Export CSV</a>
12087
+ <a href="${escapeHtml(view.exportJsonUrl)}">Export JSON</a>
12088
+ </div>
12089
+ </div>
12090
+ ${bulkBar}
12091
+ <div class="tempest-admin-table-wrap">
12092
+ <table class="tempest-admin-list__table">
12093
+ <thead><tr>${bulk ? '<th class="tempest-admin-list__check"><input type="checkbox" data-select-all aria-label="Select all"></th>' : ""}${headers}<th>Actions</th></tr></thead>
12094
+ <tbody>${rows}</tbody>
12095
+ </table>
12096
+ </div>
12097
+ ${bulk ? "</form>" : ""}
12098
+ ${selectAllScript}
12099
+ ${view.pages > 1 ? `<nav class="tempest-admin-list__pagination" aria-label="Pagination">
12100
+ ${view.prevUrl !== null ? `<a href="${escapeHtml(view.prevUrl)}">\u2190 Prev</a>` : ""}
12101
+ <span>Page ${escapeHtml(view.page)} of ${escapeHtml(view.pages)}</span>
12102
+ ${view.nextUrl !== null ? `<a href="${escapeHtml(view.nextUrl)}">Next \u2192</a>` : ""}
12103
+ </nav>` : ""}
12104
+ </section>`;
12105
+ return renderLayout(context, `${view.title} \xB7 ${context.site.title}`, body);
12106
+ }
12107
+ function renderDetailPage(context, view) {
12108
+ if (context.session === null) throw new Error("The detail view requires a session");
12109
+ const csrf = escapeHtml(context.session.csrfToken);
12110
+ const fields = view.fields.map(
12111
+ (field) => `<dt>${escapeHtml(field.label)}</dt><dd>${field.value === "" ? "<em>\u2014</em>" : escapeHtml(field.value)}</dd>`
12112
+ ).join("");
12113
+ const body = `<section class="tempest-admin-detail">
12114
+ <header class="tempest-admin-detail__header">
12115
+ <h1>${escapeHtml(view.title)} \xB7 ${escapeHtml(view.identity)}</h1>
12116
+ <div class="tempest-admin-detail__actions">
12117
+ <a href="${escapeHtml(view.backUrl)}">\u2190 Back to list</a>
12118
+ ${view.editUrl !== null ? `<a class="tempest-admin-btn" href="${escapeHtml(view.editUrl)}">Edit</a>` : ""}
12119
+ ${view.deleteUrl !== null ? `<form method="post" action="${escapeHtml(view.deleteUrl)}" class="tempest-admin-detail__delete" onsubmit="return confirm('Delete this record? This cannot be undone.');">
12120
+ <input type="hidden" name="csrf_token" value="${csrf}">
12121
+ <button type="submit" class="tempest-admin-btn--danger">Delete</button>
12122
+ </form>` : ""}
12123
+ </div>
12124
+ </header>
12125
+ <dl class="tempest-admin-detail__fields">${fields}</dl>
12126
+ </section>`;
12127
+ return renderLayout(context, `${view.title} \xB7 ${view.identity}`, body);
12128
+ }
12129
+ function renderFormField(field) {
12130
+ const required = field.required ? " required" : "";
12131
+ const name = escapeHtml(field.name);
12132
+ const value = escapeHtml(field.value);
12133
+ const label = escapeHtml(field.label);
12134
+ let control;
12135
+ switch (field.widget) {
12136
+ case "checkbox":
12137
+ control = `<label class="tempest-admin-form__check"><input type="checkbox" name="${name}" value="true"${field.checked ? " checked" : ""}><span>${label}</span></label>`;
12138
+ break;
12139
+ case "textarea":
12140
+ control = `<label><span>${label}${field.required ? " *" : ""}</span><textarea name="${name}" rows="4"${required}>${value}</textarea></label>`;
12141
+ break;
12142
+ case "json":
12143
+ control = `<label><span>${label}${field.required ? " *" : ""}</span><textarea name="${name}" rows="6" class="tempest-admin-form__json" spellcheck="false"${required}>${value}</textarea><small class="tempest-admin-form__hint">JSON \u2014 must parse (e.g. <code>{}</code>, <code>[]</code>, <code>"text"</code>).</small></label>`;
12144
+ break;
12145
+ case "select": {
12146
+ const blank = field.required ? "" : '<option value="">\u2014 none \u2014</option>';
12147
+ const options = field.options.map(
12148
+ (option) => `<option value="${escapeHtml(option.value)}"${option.value === field.value ? " selected" : ""}>${escapeHtml(option.label)}</option>`
12149
+ ).join("");
12150
+ control = `<label><span>${label}${field.required ? " *" : ""}</span><select name="${name}"${required}>${blank}${options}</select></label>`;
12151
+ break;
12152
+ }
12153
+ case "number":
12154
+ control = `<label><span>${label}${field.required ? " *" : ""}</span><input type="number" name="${name}" value="${value}"${field.step !== null ? ` step="${escapeHtml(field.step)}"` : ""}${required}></label>`;
12155
+ break;
12156
+ case "datetime":
12157
+ control = `<label><span>${label}${field.required ? " *" : ""}</span><input type="datetime-local" name="${name}" value="${value}"${required}></label>`;
12158
+ break;
12159
+ case "date":
12160
+ control = `<label><span>${label}${field.required ? " *" : ""}</span><input type="date" name="${name}" value="${value}"${required}></label>`;
12161
+ break;
12162
+ case "time":
12163
+ control = `<label><span>${label}${field.required ? " *" : ""}</span><input type="time" name="${name}" value="${value}"${required}></label>`;
12164
+ break;
12165
+ default:
12166
+ control = `<label><span>${label}${field.required ? " *" : ""}</span><input type="text" name="${name}" value="${value}"${required}></label>`;
12167
+ }
12168
+ const error = field.error !== null ? `<small class="tempest-admin-form__field-error">${escapeHtml(field.error)}</small>` : "";
12169
+ return `<div class="tempest-admin-form__field${field.error !== null ? " tempest-admin-form__field--error" : ""}">${control}${error}</div>`;
12170
+ }
12171
+ function renderFormPage(context, view) {
12172
+ if (context.session === null) throw new Error("The admin form requires a session");
12173
+ const heading = view.mode === "create" ? `New ${view.title}` : `Edit ${view.title}`;
12174
+ const body = `<section class="tempest-admin-form">
12175
+ <header class="tempest-admin-detail__header">
12176
+ <h1>${escapeHtml(heading)}</h1>
12177
+ <a href="${escapeHtml(view.backUrl)}">\u2190 Back</a>
12178
+ </header>
12179
+ ${view.error !== null ? `<p class="tempest-admin-form__error">${escapeHtml(view.error)}</p>` : ""}
12180
+ <form method="post" action="${escapeHtml(view.actionUrl)}" class="tempest-admin-form__form">
12181
+ <input type="hidden" name="csrf_token" value="${escapeHtml(context.session.csrfToken)}">
12182
+ ${view.fields.map(renderFormField).join("")}
12183
+ <div class="tempest-admin-form__actions">
12184
+ <button type="submit">${view.mode === "create" ? "Create" : "Save"}</button>
12185
+ <a href="${escapeHtml(view.backUrl)}" class="tempest-admin-form__cancel">Cancel</a>
12186
+ </div>
12187
+ </form>
12188
+ </section>`;
12189
+ return renderLayout(context, `${heading} \xB7 ${context.site.title}`, body);
12190
+ }
12191
+ var logger2 = new JSONLogger("tempest_express_sdk.admin.router");
12192
+ var FK_OPTION_CAP = 1e3;
12193
+ var FLASH_MAX_LENGTH = 300;
12194
+ var FLASH_MESSAGES = {
12195
+ created: { text: "Record created.", level: "success" },
12196
+ updated: { text: "Record updated.", level: "success" },
12197
+ deleted: { text: "Record deleted.", level: "success" }
12198
+ };
12199
+ async function runCustomAction(action, context) {
12200
+ return await action.handler(context) ?? null;
12201
+ }
12202
+ function queryString(value) {
12203
+ if (typeof value === "string") return value.trim();
12204
+ if (Array.isArray(value) && typeof value[0] === "string") return value[0].trim();
12205
+ return "";
12206
+ }
12207
+ function buildQuery(entries) {
12208
+ const params = new URLSearchParams();
12209
+ for (const [key, value] of Object.entries(entries)) {
12210
+ if (value === void 0 || value === "") continue;
12211
+ params.set(key, String(value));
12212
+ }
12213
+ return params.toString();
12214
+ }
12215
+ function makeAdminRouter(site, options) {
12216
+ const prefix = (options.prefix ?? "/admin").replace(/\/$/, "");
12217
+ const theme = resolveAdminTheme(site.theme);
12218
+ const showMetrics = options.showMetrics ?? true;
12219
+ const exportMaxRows = options.exportMaxRows ?? 5e3;
12220
+ const sessions = new AdminSessionStore({
12221
+ secret: options.secretKey,
12222
+ ...options.cookieName === void 0 ? {} : { cookieName: options.cookieName },
12223
+ ...options.sessionMaxAgeSeconds === void 0 ? {} : { maxAgeSeconds: options.sessionMaxAgeSeconds },
12224
+ ...options.cookieSecure === void 0 ? {} : { cookieSecure: options.cookieSecure },
12225
+ cookiePath: prefix === "" ? "/" : prefix
12226
+ });
12227
+ const backend = options.authBackend;
12228
+ const router = express3__default.default.Router();
12229
+ router.use(prefix, express3__default.default.urlencoded({ extended: false }));
12230
+ const context = (req, session) => ({
12231
+ site,
12232
+ theme,
12233
+ prefix,
12234
+ session,
12235
+ currentPath: req.originalUrl.split("?")[0] ?? req.path,
12236
+ navModels: site.list().map((admin) => ({
12237
+ label: admin.verboseNamePlural(),
12238
+ url: `${prefix}/m/${admin.slug()}`
12239
+ })),
12240
+ messages: flashFor(req)
12241
+ });
12242
+ const flashFor = (req) => {
12243
+ const fixed = FLASH_MESSAGES[queryString(req.query.ok)];
12244
+ if (fixed !== void 0) return [fixed];
12245
+ const text = queryString(req.query.flash);
12246
+ if (text === "") return [];
12247
+ const level = queryString(req.query.level);
12248
+ return [
12249
+ {
12250
+ text: text.slice(0, FLASH_MAX_LENGTH),
12251
+ level: level === "error" || level === "warning" ? level : "success"
12252
+ }
12253
+ ];
12254
+ };
12255
+ const html = (res, body, status = 200) => {
12256
+ res.status(status).type("html").send(body);
12257
+ };
12258
+ const guarded = (handler) => (req, res) => {
12259
+ handler(req, res).catch((error) => {
12260
+ logger2.error("Admin request failed", {
12261
+ path: req.originalUrl,
12262
+ error: error instanceof Error ? error.message : String(error)
12263
+ });
12264
+ if (res.headersSent) return;
12265
+ html(
12266
+ res,
12267
+ renderLoginPage(context(req, null), "Something went wrong. Please try again."),
12268
+ 500
12269
+ );
12270
+ });
12271
+ };
12272
+ const authenticate = async (req, res) => {
12273
+ const session = sessions.load(req);
12274
+ if (session === null) {
12275
+ res.redirect(`${prefix}/login`);
12276
+ return null;
12277
+ }
12278
+ const dbSession = options.engine.session();
12279
+ const principal = await backend.loadPrincipal(dbSession, session.subject);
12280
+ if (principal === null) {
12281
+ sessions.clear(res);
12282
+ res.redirect(`${prefix}/login`);
12283
+ return null;
12284
+ }
12285
+ if (!session.mfaPassed) {
12286
+ res.redirect(`${prefix}/mfa`);
12287
+ return null;
12288
+ }
12289
+ return { session, dbSession, principal };
12290
+ };
12291
+ const resolveAdmin = (req, res, state) => {
12292
+ const admin = site.get(String(req.params.slug));
12293
+ if (admin === null) {
12294
+ html(res, renderNotFound(context(req, state.session)), 404);
12295
+ return null;
12296
+ }
12297
+ return admin;
12298
+ };
12299
+ const renderNotFound = (ctx) => renderDashboardPage(
12300
+ { ...ctx, messages: [{ text: "Not found.", level: "error" }] },
12301
+ [],
12302
+ null
12303
+ );
12304
+ const checkCsrf = (req, res, state) => {
12305
+ const body = req.body;
12306
+ if (csrfTokenMatches(state.session, body?.csrf_token)) return true;
12307
+ html(res, renderNotFound(context(req, state.session)), 403);
12308
+ return false;
12309
+ };
12310
+ router.get(`${prefix}/static/admin.css`, (_req, res) => {
12311
+ res.type("css").set("cache-control", "public, max-age=3600").send(ADMIN_CSS);
12312
+ });
12313
+ router.get(`${prefix}/login`, (req, res) => {
12314
+ html(res, renderLoginPage(context(req, null), null));
12315
+ });
12316
+ router.post(
12317
+ `${prefix}/login`,
12318
+ guarded(async (req, res) => {
12319
+ const body = req.body;
12320
+ const identifier = typeof body.identifier === "string" ? body.identifier : "";
12321
+ const password = typeof body.password === "string" ? body.password : "";
12322
+ const dbSession = options.engine.session();
12323
+ const principal = await backend.authenticate(dbSession, identifier, password);
12324
+ if (principal === null) {
12325
+ html(res, renderLoginPage(context(req, null), "Invalid credentials."), 401);
12326
+ return;
12327
+ }
12328
+ const needsMfa = await backend.mfaEnabled?.(principal) ?? false;
12329
+ const session = sessions.issue(
12330
+ backend.principalId(principal),
12331
+ backend.displayName(principal),
12332
+ !needsMfa
12333
+ );
12334
+ sessions.save(res, session);
12335
+ res.redirect(needsMfa ? `${prefix}/mfa` : `${prefix}/`);
12336
+ })
12337
+ );
12338
+ router.get(`${prefix}/mfa`, (req, res) => {
12339
+ const session = sessions.load(req);
12340
+ if (session === null) {
12341
+ res.redirect(`${prefix}/login`);
12342
+ return;
12343
+ }
12344
+ if (session.mfaPassed) {
12345
+ res.redirect(`${prefix}/`);
12346
+ return;
12347
+ }
12348
+ html(res, renderMfaPage(context(req, null), null));
12349
+ });
12350
+ router.post(
12351
+ `${prefix}/mfa`,
12352
+ guarded(async (req, res) => {
12353
+ const session = sessions.load(req);
12354
+ if (session === null) {
12355
+ res.redirect(`${prefix}/login`);
12356
+ return;
12357
+ }
12358
+ const body = req.body;
12359
+ const code = typeof body.code === "string" ? body.code : "";
12360
+ const dbSession = options.engine.session();
12361
+ const principal = await backend.loadPrincipal(dbSession, session.subject);
12362
+ const verified = principal !== null && (await backend.verifyMfa?.(principal, code) ?? false);
12363
+ if (!verified) {
12364
+ html(res, renderMfaPage(context(req, null), "Invalid code."), 401);
12365
+ return;
12366
+ }
12367
+ sessions.save(res, { ...session, mfaPassed: true });
12368
+ res.redirect(`${prefix}/`);
12369
+ })
12370
+ );
12371
+ router.post(
12372
+ `${prefix}/logout`,
12373
+ guarded(async (req, res) => {
12374
+ const session = sessions.load(req);
12375
+ if (session !== null && !csrfTokenMatches(session, req.body?.csrf_token)) {
12376
+ html(res, renderLoginPage(context(req, null), "Invalid request."), 403);
12377
+ return;
12378
+ }
12379
+ sessions.clear(res);
12380
+ res.redirect(`${prefix}/login`);
12381
+ })
12382
+ );
12383
+ router.get(
12384
+ `${prefix}/`,
12385
+ guarded(async (req, res) => {
12386
+ const state = await authenticate(req, res);
12387
+ if (state === null) return;
12388
+ const cards = [];
12389
+ for (const admin of site.list()) {
12390
+ let count = null;
12391
+ try {
12392
+ count = await admin.repository(state.dbSession).count();
12393
+ } catch (error) {
12394
+ logger2.warning("Admin dashboard count failed", {
12395
+ slug: admin.slug(),
12396
+ error: error instanceof Error ? error.message : String(error)
12397
+ });
12398
+ }
12399
+ cards.push({
12400
+ label: admin.verboseNamePlural(),
12401
+ count,
12402
+ url: `${prefix}/m/${admin.slug()}`,
12403
+ newUrl: admin.canCreate ? `${prefix}/m/${admin.slug()}/new` : null
12404
+ });
12405
+ }
12406
+ let metrics = null;
12407
+ if (showMetrics) {
12408
+ const snapshot2 = MetricsUtils.system();
12409
+ metrics = {
12410
+ cpuPercent: Math.round(snapshot2.cpu.loadPercent),
12411
+ memoryPercent: Math.round(snapshot2.memory.usedPercent),
12412
+ memoryUsedGb: (snapshot2.memory.used / 1024 ** 3).toFixed(1),
12413
+ memoryTotalGb: (snapshot2.memory.total / 1024 ** 3).toFixed(1)
12414
+ };
12415
+ }
12416
+ html(res, renderDashboardPage(context(req, state.session), cards, metrics));
12417
+ })
12418
+ );
12419
+ router.get(
12420
+ `${prefix}/m/:slug`,
12421
+ guarded(async (req, res) => {
12422
+ const state = await authenticate(req, res);
12423
+ if (state === null) return;
12424
+ const admin = resolveAdmin(req, res, state);
12425
+ if (admin === null) return;
12426
+ html(res, await renderList(req, admin, state));
12427
+ })
12428
+ );
12429
+ router.get(
12430
+ `${prefix}/m/:slug/new`,
12431
+ guarded(async (req, res) => {
12432
+ const state = await authenticate(req, res);
12433
+ if (state === null) return;
12434
+ const admin = resolveAdmin(req, res, state);
12435
+ if (admin === null) return;
12436
+ if (!admin.canCreate) {
12437
+ html(res, renderNotFound(context(req, state.session)), 404);
12438
+ return;
12439
+ }
12440
+ html(
12441
+ res,
12442
+ renderFormPage(context(req, state.session), {
12443
+ mode: "create",
12444
+ title: admin.verboseName(),
12445
+ fields: buildFormFields(admin, {
12446
+ foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession)
12447
+ }),
12448
+ actionUrl: `${prefix}/m/${admin.slug()}/new`,
12449
+ backUrl: `${prefix}/m/${admin.slug()}`,
12450
+ error: null
12451
+ })
12452
+ );
12453
+ })
12454
+ );
12455
+ router.post(
12456
+ `${prefix}/m/:slug/new`,
12457
+ guarded(async (req, res) => {
12458
+ const state = await authenticate(req, res);
12459
+ if (state === null) return;
12460
+ const admin = resolveAdmin(req, res, state);
12461
+ if (admin === null) return;
12462
+ if (!admin.canCreate) {
12463
+ html(res, renderNotFound(context(req, state.session)), 404);
12464
+ return;
12465
+ }
12466
+ if (!checkCsrf(req, res, state)) return;
12467
+ const body = req.body;
12468
+ const parsed = parseFormBody(admin, body);
12469
+ const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
12470
+ const rerender = (error, status) => {
12471
+ html(
12472
+ res,
12473
+ renderFormPage(context(req, state.session), {
12474
+ mode: "create",
12475
+ title: admin.verboseName(),
12476
+ fields: buildFormFields(admin, {
12477
+ values: body,
12478
+ errors: parsed.errors,
12479
+ foreignKeyOptions
12480
+ }),
12481
+ actionUrl: `${prefix}/m/${admin.slug()}/new`,
12482
+ backUrl: `${prefix}/m/${admin.slug()}`,
12483
+ error
12484
+ }),
12485
+ status
12486
+ );
12487
+ };
12488
+ if (Object.keys(parsed.errors).length > 0) {
12489
+ rerender("Please fix the highlighted fields.", 400);
12490
+ return;
12491
+ }
12492
+ try {
12493
+ await admin.repository(state.dbSession).create(parsed.data);
12494
+ } catch (error) {
12495
+ rerender(describeWriteFailure(admin, error), 400);
12496
+ return;
12497
+ }
12498
+ res.redirect(`${prefix}/m/${admin.slug()}?ok=created`);
12499
+ })
12500
+ );
12501
+ router.get(
12502
+ `${prefix}/m/:slug/export.:format`,
12503
+ guarded(async (req, res) => {
12504
+ const state = await authenticate(req, res);
12505
+ if (state === null) return;
12506
+ const admin = resolveAdmin(req, res, state);
12507
+ if (admin === null) return;
12508
+ const format = String(req.params.format);
12509
+ if (format !== "csv" && format !== "json") {
12510
+ html(res, renderNotFound(context(req, state.session)), 404);
12511
+ return;
12512
+ }
12513
+ const query = await resolveListQuery(req, admin, state.dbSession);
12514
+ const result = await admin.repository(state.dbSession).paginate({
12515
+ page: 1,
12516
+ pageSize: exportMaxRows,
12517
+ ...query.orderBy === void 0 ? {} : { orderBy: query.orderBy },
12518
+ ascending: query.ascending,
12519
+ ...query.where === void 0 ? {} : { filters: query.where }
12520
+ });
12521
+ const columns = admin.listDisplayNames();
12522
+ const rows = result.items;
12523
+ const payload = format === "csv" ? toCsv(columns, rows) : toJson(columns, rows);
12524
+ res.status(200).type(format === "csv" ? "text/csv; charset=utf-8" : "application/json").set("content-disposition", `attachment; filename="${admin.slug()}.${format}"`).send(payload);
12525
+ })
12526
+ );
12527
+ router.post(
12528
+ `${prefix}/m/:slug/bulk`,
12529
+ guarded(async (req, res) => {
12530
+ const state = await authenticate(req, res);
12531
+ if (state === null) return;
12532
+ const admin = resolveAdmin(req, res, state);
12533
+ if (admin === null) return;
12534
+ if (!checkCsrf(req, res, state)) return;
12535
+ const body = req.body;
12536
+ const action = typeof body.action === "string" ? body.action : "";
12537
+ const raw = body.ids;
12538
+ const ids = (Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw]).filter((value) => typeof value === "string").filter((value) => value !== "");
12539
+ const listUrl = `${prefix}/m/${admin.slug()}`;
12540
+ const back = (message, level) => {
12541
+ res.redirect(`${listUrl}?${buildQuery({ flash: message, level })}`);
12542
+ };
12543
+ if (ids.length === 0) {
12544
+ back("No rows were selected.", "warning");
12545
+ return;
12546
+ }
12547
+ if (!bulkActionsFor(admin).some((option) => option.value === action)) {
12548
+ html(res, renderNotFound(context(req, state.session)), 400);
12549
+ return;
12550
+ }
12551
+ const repository = admin.repository(state.dbSession);
12552
+ const scope = { [admin.identityField]: { in: ids } };
12553
+ if (action.startsWith("custom:")) {
12554
+ const custom = admin.getAction(action.slice("custom:".length));
12555
+ if (custom === null) {
12556
+ html(res, renderNotFound(context(req, state.session)), 400);
12557
+ return;
12558
+ }
12559
+ let result;
12560
+ try {
12561
+ result = await runCustomAction(custom, {
12562
+ ids,
12563
+ repository,
12564
+ dbSession: state.dbSession,
12565
+ request: req,
12566
+ session: state.session,
12567
+ principal: state.principal
12568
+ });
12569
+ } catch (error) {
12570
+ logger2.error("Admin action failed", {
12571
+ slug: admin.slug(),
12572
+ action: custom.name,
12573
+ error: error instanceof Error ? error.message : String(error)
12574
+ });
12575
+ back(
12576
+ `${custom.label} failed: ${error instanceof Error ? error.message : String(error)}`,
12577
+ "error"
12578
+ );
12579
+ return;
12580
+ }
12581
+ if (result === null) {
12582
+ res.redirect(listUrl);
12583
+ return;
12584
+ }
12585
+ back(result.message, result.category ?? "success");
12586
+ return;
12587
+ }
12588
+ if (action === "delete") {
12589
+ const removed = await repository.delete(scope);
12590
+ back(`Deleted ${removed} record${removed === 1 ? "" : "s"}.`, "success");
12591
+ return;
12592
+ }
12593
+ const active = action === "activate";
12594
+ const changed = await repository.update(scope, { isActive: active });
12595
+ back(
12596
+ `${active ? "Activated" : "Deactivated"} ${changed} record${changed === 1 ? "" : "s"}.`,
12597
+ "success"
12598
+ );
12599
+ })
12600
+ );
12601
+ router.get(
12602
+ `${prefix}/m/:slug/:identity`,
12603
+ guarded(async (req, res) => {
12604
+ const state = await authenticate(req, res);
12605
+ if (state === null) return;
12606
+ const admin = resolveAdmin(req, res, state);
12607
+ if (admin === null) return;
12608
+ const row = await findRow(admin, state.dbSession, String(req.params.identity));
12609
+ if (row === null) {
12610
+ html(res, renderNotFound(context(req, state.session)), 404);
12611
+ return;
12612
+ }
12613
+ const identity = String(row[admin.identityField]);
12614
+ html(
12615
+ res,
12616
+ renderDetailPage(context(req, state.session), {
12617
+ title: admin.verboseName(),
12618
+ identity,
12619
+ fields: admin.detailFieldNames().map((name) => ({ label: name, value: formatCellValue(row[name]) })),
12620
+ backUrl: `${prefix}/m/${admin.slug()}`,
12621
+ editUrl: admin.canEdit ? `${prefix}/m/${admin.slug()}/${identity}/edit` : null,
12622
+ deleteUrl: admin.canDelete ? `${prefix}/m/${admin.slug()}/${identity}/delete` : null
12623
+ })
12624
+ );
12625
+ })
12626
+ );
12627
+ router.get(
12628
+ `${prefix}/m/:slug/:identity/edit`,
12629
+ guarded(async (req, res) => {
12630
+ const state = await authenticate(req, res);
12631
+ if (state === null) return;
12632
+ const admin = resolveAdmin(req, res, state);
12633
+ if (admin === null) return;
12634
+ const identity = String(req.params.identity);
12635
+ const row = admin.canEdit ? await findRow(admin, state.dbSession, identity) : null;
12636
+ if (row === null) {
12637
+ html(res, renderNotFound(context(req, state.session)), 404);
12638
+ return;
12639
+ }
12640
+ html(
12641
+ res,
12642
+ renderFormPage(context(req, state.session), {
12643
+ mode: "edit",
12644
+ title: admin.verboseName(),
12645
+ fields: buildFormFields(admin, {
12646
+ values: row,
12647
+ foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession)
12648
+ }),
12649
+ actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
12650
+ backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
12651
+ error: null
12652
+ })
12653
+ );
12654
+ })
12655
+ );
12656
+ router.post(
12657
+ `${prefix}/m/:slug/:identity/edit`,
12658
+ guarded(async (req, res) => {
12659
+ const state = await authenticate(req, res);
12660
+ if (state === null) return;
12661
+ const admin = resolveAdmin(req, res, state);
12662
+ if (admin === null) return;
12663
+ const identity = String(req.params.identity);
12664
+ if (!admin.canEdit) {
12665
+ html(res, renderNotFound(context(req, state.session)), 404);
12666
+ return;
12667
+ }
12668
+ if (!checkCsrf(req, res, state)) return;
12669
+ const body = req.body;
12670
+ const parsed = parseFormBody(admin, body);
12671
+ const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
12672
+ const rerender = (error, status) => {
12673
+ html(
12674
+ res,
12675
+ renderFormPage(context(req, state.session), {
12676
+ mode: "edit",
12677
+ title: admin.verboseName(),
12678
+ fields: buildFormFields(admin, {
12679
+ values: body,
12680
+ errors: parsed.errors,
12681
+ foreignKeyOptions
12682
+ }),
12683
+ actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
12684
+ backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
12685
+ error
12686
+ }),
12687
+ status
12688
+ );
12689
+ };
12690
+ if (Object.keys(parsed.errors).length > 0) {
12691
+ rerender("Please fix the highlighted fields.", 400);
12692
+ return;
12693
+ }
12694
+ try {
12695
+ const changed = await admin.repository(state.dbSession).update({ [admin.identityField]: identity }, parsed.data);
12696
+ if (changed === 0) {
12697
+ html(res, renderNotFound(context(req, state.session)), 404);
12698
+ return;
12699
+ }
12700
+ } catch (error) {
12701
+ rerender(describeWriteFailure(admin, error), 400);
12702
+ return;
12703
+ }
12704
+ res.redirect(`${prefix}/m/${admin.slug()}/${identity}?ok=updated`);
12705
+ })
12706
+ );
12707
+ router.post(
12708
+ `${prefix}/m/:slug/:identity/delete`,
12709
+ guarded(async (req, res) => {
12710
+ const state = await authenticate(req, res);
12711
+ if (state === null) return;
12712
+ const admin = resolveAdmin(req, res, state);
12713
+ if (admin === null) return;
12714
+ if (!admin.canDelete) {
12715
+ html(res, renderNotFound(context(req, state.session)), 404);
12716
+ return;
12717
+ }
12718
+ if (!checkCsrf(req, res, state)) return;
12719
+ await admin.repository(state.dbSession).delete({ [admin.identityField]: String(req.params.identity) });
12720
+ res.redirect(`${prefix}/m/${admin.slug()}?ok=deleted`);
12721
+ })
12722
+ );
12723
+ async function findRow(admin, dbSession, identity) {
12724
+ const row = await admin.repository(dbSession).first({ [admin.identityField]: identity });
12725
+ return row ?? null;
12726
+ }
12727
+ async function renderList(req, admin, state) {
12728
+ const columns = adminColumns(admin.model);
12729
+ const query = await resolveListQuery(req, admin, state.dbSession);
12730
+ const page2 = Math.max(1, Number.parseInt(queryString(req.query.page), 10) || 1);
12731
+ const result = await admin.repository(state.dbSession).paginate({
12732
+ page: page2,
12733
+ pageSize: admin.pageSize,
12734
+ ...query.orderBy === void 0 ? {} : { orderBy: query.orderBy },
12735
+ ascending: query.ascending,
12736
+ ...query.where === void 0 ? {} : { filters: query.where }
12737
+ });
12738
+ const displayed = admin.listDisplayNames();
12739
+ const sort = {};
12740
+ for (const column6 of displayed) {
12741
+ if (!(column6 in columns)) continue;
12742
+ const active = (query.sortColumn ?? admin.orderKey) === column6;
12743
+ const nextAscending = active ? !query.ascending : true;
12744
+ sort[column6] = {
12745
+ url: `?${buildQuery({
12746
+ ...query.baseQuery,
12747
+ sort: column6,
12748
+ dir: nextAscending ? "asc" : "desc"
12749
+ })}`,
12750
+ active,
12751
+ ascending: query.ascending
12752
+ };
12753
+ }
12754
+ const sortParams = {
12755
+ sort: query.sortColumn ?? void 0,
12756
+ dir: query.sortColumn === null ? void 0 : query.ascending ? "asc" : "desc"
12757
+ };
12758
+ const pageUrl = (target) => `?${buildQuery({ ...query.baseQuery, ...sortParams, page: target })}`;
12759
+ const exportQuery = buildQuery({ ...query.baseQuery, ...sortParams });
12760
+ const exportUrl = (format) => `${prefix}/m/${admin.slug()}/export.${format}${exportQuery === "" ? "" : `?${exportQuery}`}`;
12761
+ const view = {
12762
+ title: admin.verboseNamePlural(),
12763
+ columns: displayed,
12764
+ rows: result.items.map((row) => {
12765
+ const identity = String(row[admin.identityField]);
12766
+ return {
12767
+ identity,
12768
+ cells: displayed.map((column6) => formatCellValue(row[column6])),
12769
+ url: `${prefix}/m/${admin.slug()}/${identity}`
12770
+ };
12771
+ }),
12772
+ total: result.total,
12773
+ page: result.page,
12774
+ pages: result.pages,
12775
+ prevUrl: result.page > 1 ? pageUrl(result.page - 1) : null,
12776
+ nextUrl: result.page < result.pages ? pageUrl(result.page + 1) : null,
12777
+ searchable: query.searchable.length > 0,
12778
+ searchValue: query.search,
12779
+ filters: query.filterViews,
12780
+ sort,
12781
+ newUrl: admin.canCreate ? `${prefix}/m/${admin.slug()}/new` : null,
12782
+ bulkActions: bulkActionsFor(admin),
12783
+ bulkUrl: `${prefix}/m/${admin.slug()}/bulk`,
12784
+ exportCsvUrl: exportUrl("csv"),
12785
+ exportJsonUrl: exportUrl("json")
12786
+ };
12787
+ return renderListPage(context(req, state.session), view);
12788
+ }
12789
+ async function resolveListQuery(req, admin, dbSession) {
12790
+ const columns = adminColumns(admin.model);
12791
+ const search = queryString(req.query.q);
12792
+ const sortField = queryString(req.query.sort);
12793
+ const sortColumn = sortField in columns ? sortField : null;
12794
+ const ascending = sortColumn === null ? admin.orderAscending : queryString(req.query.dir) !== "desc";
12795
+ const conditions = [];
12796
+ const filterViews = [];
12797
+ for (const field of admin.listFilter) {
12798
+ const column6 = columns[field];
12799
+ if (column6 === void 0) continue;
12800
+ const spec = filterForColumn(column6);
12801
+ if (spec.kind === "daterange") {
12802
+ const from = queryString(req.query[`filter_${field}_from`]);
12803
+ const to = queryString(req.query[`filter_${field}_to`]);
12804
+ const bounds = {};
12805
+ if (from !== "") bounds.gte = new Date(from);
12806
+ if (to !== "") bounds.lte = /* @__PURE__ */ new Date(`${to}T23:59:59.999Z`);
12807
+ if (Object.keys(bounds).length > 0) conditions.push({ [field]: bounds });
12808
+ filterViews.push({
12809
+ field,
12810
+ label: humanizeField(field),
12811
+ kind: "daterange",
12812
+ value: "",
12813
+ valueFrom: from,
12814
+ valueTo: to,
12815
+ options: []
12816
+ });
12817
+ continue;
12818
+ }
12819
+ const related = await relatedOptions(column6, dbSession);
12820
+ const options2 = related ?? spec.options;
12821
+ const value = queryString(req.query[`filter_${field}`]);
12822
+ if (value !== "") {
12823
+ conditions.push({
12824
+ [field]: column6.type.kind === "boolean" ? value === "true" : value
12825
+ });
12826
+ }
12827
+ filterViews.push({
12828
+ field,
12829
+ label: humanizeField(field),
12830
+ kind: related === null ? spec.kind : "select",
12831
+ value,
12832
+ valueFrom: "",
12833
+ valueTo: "",
12834
+ options: options2.map((option) => ({
12835
+ value: option.value,
12836
+ label: option.label,
12837
+ selected: option.value === value
12838
+ }))
12839
+ });
12840
+ }
12841
+ const searchable = admin.searchFields.filter((field) => {
12842
+ const column6 = columns[field];
12843
+ return column6 !== void 0 && isSearchableColumn(column6);
12844
+ });
12845
+ if (search !== "" && searchable.length > 0) {
12846
+ conditions.push(
12847
+ tempestDbJs.or(...searchable.map((field) => ({ [field]: { ilike: `%${search}%` } })))
12848
+ );
12849
+ }
12850
+ const baseQuery = { q: search };
12851
+ for (const view of filterViews) {
12852
+ if (view.kind === "daterange") {
12853
+ baseQuery[`filter_${view.field}_from`] = view.valueFrom;
12854
+ baseQuery[`filter_${view.field}_to`] = view.valueTo;
12855
+ } else {
12856
+ baseQuery[`filter_${view.field}`] = view.value;
12857
+ }
12858
+ }
12859
+ return {
12860
+ search,
12861
+ searchable,
12862
+ where: conditions.length === 0 ? void 0 : tempestDbJs.and(...conditions),
12863
+ orderBy: sortColumn ?? admin.orderKey ?? void 0,
12864
+ ascending,
12865
+ sortColumn,
12866
+ filterViews,
12867
+ baseQuery
12868
+ };
12869
+ }
12870
+ async function relatedOptions(column6, dbSession) {
12871
+ const table = foreignKeyTable(column6);
12872
+ if (table === null) return null;
12873
+ const referenced = site.get(table);
12874
+ if (referenced === null) return null;
12875
+ const rows = await referenced.repository(dbSession).list();
12876
+ return rows.slice(0, FK_OPTION_CAP).map((row) => ({
12877
+ value: String(row[referenced.identityField]),
12878
+ label: foreignKeyLabel(referenced, row)
12879
+ }));
12880
+ }
12881
+ async function foreignKeyOptionsFor(admin, dbSession) {
12882
+ const columns = adminColumns(admin.model);
12883
+ const options2 = {};
12884
+ for (const field of Object.keys(foreignKeyFields(admin))) {
12885
+ const column6 = columns[field];
12886
+ if (column6 === void 0) continue;
12887
+ const related = await relatedOptions(column6, dbSession);
12888
+ if (related !== null) options2[field] = related;
12889
+ }
12890
+ return options2;
12891
+ }
12892
+ return router;
12893
+ }
12894
+ function bulkActionsFor(admin) {
12895
+ const actions = [];
12896
+ const hasActiveFlag = "isActive" in adminColumns(admin.model);
12897
+ if (admin.canEdit && hasActiveFlag) {
12898
+ actions.push({ value: "activate", label: "Activate", dangerous: false });
12899
+ actions.push({ value: "deactivate", label: "Deactivate", dangerous: false });
12900
+ }
12901
+ if (admin.canDelete) {
12902
+ actions.push({ value: "delete", label: "Delete", dangerous: true });
12903
+ }
12904
+ for (const action of admin.customActions()) {
12905
+ actions.push({
12906
+ value: `custom:${action.name}`,
12907
+ label: action.label,
12908
+ dangerous: action.dangerous
12909
+ });
12910
+ }
12911
+ return actions;
12912
+ }
12913
+ function exportValue(value) {
12914
+ if (value instanceof Date) return value.toISOString();
12915
+ if (typeof value === "bigint") return value.toString();
12916
+ if (value instanceof Uint8Array) return Buffer.from(value).toString("base64");
12917
+ return value;
12918
+ }
12919
+ function csvField(value) {
12920
+ if (value === null || value === void 0) return "";
12921
+ const text = typeof value === "object" ? JSON.stringify(value) : String(value);
12922
+ if (/[",\r\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
12923
+ return text;
12924
+ }
12925
+ function toCsv(columns, rows) {
12926
+ const lines = [columns.map(csvField).join(",")];
12927
+ for (const row of rows) {
12928
+ lines.push(columns.map((column6) => csvField(exportValue(row[column6]))).join(","));
12929
+ }
12930
+ return `${lines.join("\r\n")}\r
12931
+ `;
12932
+ }
12933
+ function toJson(columns, rows) {
12934
+ return JSON.stringify(
12935
+ rows.map(
12936
+ (row) => Object.fromEntries(columns.map((column6) => [column6, exportValue(row[column6])]))
12937
+ ),
12938
+ null,
12939
+ 2
12940
+ );
12941
+ }
12942
+ function describeWriteFailure(admin, error) {
12943
+ const detail = error instanceof Error ? error.message : String(error);
12944
+ return `The database refused this ${admin.verboseName().toLowerCase()}: ${detail}`;
12945
+ }
12946
+
12947
+ // src/admin/json/site.ts
12948
+ var AdminJsonSite = class {
9449
12949
  /**
9450
12950
  * @param brand - Display name surfaced under `GET {prefix}/`.
9451
12951
  */
@@ -9486,9 +12986,9 @@ function requireResource(site, name) {
9486
12986
  if (!resource) throw new NotFoundException({ message: `Unknown resource: ${name}` });
9487
12987
  return resource;
9488
12988
  }
9489
- function makeAdminRouter(site, options = {}) {
12989
+ function makeAdminJsonRouter(site, options = {}) {
9490
12990
  const prefix = (options.prefix ?? "/admin").replace(/\/$/, "");
9491
- const router = express2.Router();
12991
+ const router = express3.Router();
9492
12992
  if (options.guard) router.use(prefix, options.guard);
9493
12993
  router.get(prefix, (_req, res) => {
9494
12994
  res.json({
@@ -10004,7 +13504,7 @@ function registerPaths(registry, prefix) {
10004
13504
  function makeAuthRouter(options) {
10005
13505
  const { service, jwt, prefix = "/auth", registry } = options;
10006
13506
  if (registry) registerPaths(registry, prefix);
10007
- const router = express2.Router();
13507
+ const router = express3.Router();
10008
13508
  router.post(`${prefix}/signup`, async (req, res) => {
10009
13509
  const result = await service.signup(signupSchema.parse(req.body));
10010
13510
  res.status(201).json(result);
@@ -10125,7 +13625,7 @@ function renderPasswordResetFormPage(options) {
10125
13625
  </form>`
10126
13626
  );
10127
13627
  }
10128
- var logger2 = new JSONLogger("tempest_express_sdk.api.handlers");
13628
+ var logger3 = new JSONLogger("tempest_express_sdk.api.handlers");
10129
13629
  var REQUEST_ID_HEADER = "X-Request-ID";
10130
13630
  var VALID_REQUEST_ID = /^[A-Za-z0-9._\-:+/=]{1,128}$/;
10131
13631
  function requestIdMiddleware() {
@@ -10167,7 +13667,7 @@ function makeAppExceptionHandler(options = {}) {
10167
13667
  return;
10168
13668
  }
10169
13669
  const isServerError = exc.statusCode >= 500;
10170
- logger2.log(isServerError ? serverErrorLevel : "info", "AppException handled", {
13670
+ logger3.log(isServerError ? serverErrorLevel : "info", "AppException handled", {
10171
13671
  path: req.path,
10172
13672
  method: req.method,
10173
13673
  statusCode: exc.statusCode,
@@ -10191,7 +13691,7 @@ function makeUnhandledExceptionHandler(options = {}) {
10191
13691
  const { includeStack = false, logLevel = "error" } = options;
10192
13692
  return (err, req, res, _next) => {
10193
13693
  const error = err instanceof Error ? err : new Error(String(err));
10194
- logger2.log(logLevel, "Unhandled exception", {
13694
+ logger3.log(logLevel, "Unhandled exception", {
10195
13695
  path: req.path,
10196
13696
  method: req.method,
10197
13697
  [HTTP_500_MARKER]: true,
@@ -10245,7 +13745,7 @@ function generateOpenApiDocument(registry, options) {
10245
13745
  var DEFAULT_DOCS_FAVICON = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiI+PHJlY3Qgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIiByeD0iNyIgZmlsbD0iIzRjNmVmNSIvPjxwYXRoIGQ9Ik0xNy45IDQuNSA4LjYgMTguNGg1LjJMMTMgMjcuNWw5LjQtMTMuOWgtNS4zeiIgZmlsbD0iI2ZmZiIvPjwvc3ZnPg==";
10246
13746
  var REDOC_CDN_URL = "https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js";
10247
13747
  var REDOC_BUNDLE_SPECIFIER = "redoc/bundles/redoc.standalone.js";
10248
- function escapeHtml(value) {
13748
+ function escapeHtml2(value) {
10249
13749
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
10250
13750
  }
10251
13751
  function scriptLiteral(value) {
@@ -10254,7 +13754,7 @@ function scriptLiteral(value) {
10254
13754
  function faviconTag(favicon) {
10255
13755
  if (favicon === false) return "";
10256
13756
  return `
10257
- <link rel="icon" href="${escapeHtml(favicon)}" />`;
13757
+ <link rel="icon" href="${escapeHtml2(favicon)}" />`;
10258
13758
  }
10259
13759
  function mountOpenApiJson(app, path, document) {
10260
13760
  app.get(path, (_req, res) => {
@@ -10298,19 +13798,19 @@ function swaggerHtml(options) {
10298
13798
  };
10299
13799
  const standalone = merged.layout === "StandaloneLayout";
10300
13800
  const presetScript = standalone ? `
10301
- <script src="${escapeHtml(assetsBase)}/swagger-ui-standalone-preset.js"></script>` : "";
13801
+ <script src="${escapeHtml2(assetsBase)}/swagger-ui-standalone-preset.js"></script>` : "";
10302
13802
  const presets = standalone ? "[SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset]" : "[SwaggerUIBundle.presets.apis]";
10303
13803
  return `<!doctype html>
10304
13804
  <html lang="en">
10305
13805
  <head>
10306
13806
  <meta charset="utf-8" />
10307
13807
  <meta name="viewport" content="width=device-width, initial-scale=1" />
10308
- <title>${escapeHtml(title)}</title>${faviconTag(favicon)}
10309
- <link rel="stylesheet" href="${escapeHtml(assetsBase)}/swagger-ui.css" />
13808
+ <title>${escapeHtml2(title)}</title>${faviconTag(favicon)}
13809
+ <link rel="stylesheet" href="${escapeHtml2(assetsBase)}/swagger-ui.css" />
10310
13810
  </head>
10311
13811
  <body>
10312
13812
  <div id="swagger-ui"></div>
10313
- <script src="${escapeHtml(assetsBase)}/swagger-ui-bundle.js"></script>${presetScript}
13813
+ <script src="${escapeHtml2(assetsBase)}/swagger-ui-bundle.js"></script>${presetScript}
10314
13814
  <script>
10315
13815
  var options = ${scriptLiteral(merged)};
10316
13816
  options.presets = ${presets};
@@ -10325,7 +13825,7 @@ function mountSwaggerUi(app, path, specUrl, options = {}) {
10325
13825
  const ui = options.ui ?? {};
10326
13826
  assertSerializableUiOptions(ui);
10327
13827
  const assetsPath = `${path.replace(/\/$/, "")}/assets`;
10328
- app.use(assetsPath, express2__default.default.static(swaggerUiDist.getAbsoluteFSPath()));
13828
+ app.use(assetsPath, express3__default.default.static(swaggerUiDist.getAbsoluteFSPath()));
10329
13829
  const handler = (_req, res) => {
10330
13830
  res.type("html").send(swaggerHtml({ specUrl, title, assetsBase: assetsPath, favicon, ui }));
10331
13831
  };
@@ -10345,7 +13845,7 @@ function redocHtml(specUrl, title, scriptUrl, favicon) {
10345
13845
  <head>
10346
13846
  <meta charset="utf-8" />
10347
13847
  <meta name="viewport" content="width=device-width, initial-scale=1" />
10348
- <title>${escapeHtml(title)}</title>${faviconTag(favicon)}
13848
+ <title>${escapeHtml2(title)}</title>${faviconTag(favicon)}
10349
13849
  <style>
10350
13850
  body { margin: 0; padding: 0; }
10351
13851
  #redoc-load-error {
@@ -10377,7 +13877,7 @@ function redocHtml(specUrl, title, scriptUrl, favicon) {
10377
13877
  <code>scriptUrl</code> at a copy you host.
10378
13878
  </p>
10379
13879
  </div>
10380
- <redoc spec-url="${escapeHtml(specUrl)}"></redoc>
13880
+ <redoc spec-url="${escapeHtml2(specUrl)}"></redoc>
10381
13881
  <script>
10382
13882
  window.__redocLoadFailed = function () {
10383
13883
  document.getElementById("redoc-script-url").textContent = ${scriptLiteral(scriptUrl)};
@@ -10387,7 +13887,7 @@ function redocHtml(specUrl, title, scriptUrl, favicon) {
10387
13887
  if (element) element.style.display = "none";
10388
13888
  };
10389
13889
  </script>
10390
- <script src="${escapeHtml(scriptUrl)}" onerror="window.__redocLoadFailed()"></script>
13890
+ <script src="${escapeHtml2(scriptUrl)}" onerror="window.__redocLoadFailed()"></script>
10391
13891
  </body>
10392
13892
  </html>`;
10393
13893
  }
@@ -10419,7 +13919,7 @@ function mountRedoc(app, path, specUrl, options = {}) {
10419
13919
  function makeHealthRouter(options = {}) {
10420
13920
  const path = options.path ?? "/health";
10421
13921
  const checks = options.checks ?? [];
10422
- const router = express2.Router();
13922
+ const router = express3.Router();
10423
13923
  router.get(path, async (_req, res) => {
10424
13924
  const results = {};
10425
13925
  let healthy = true;
@@ -10442,7 +13942,7 @@ function makeHealthRouter(options = {}) {
10442
13942
  }
10443
13943
  function makeMetricsRouter(options = {}) {
10444
13944
  const path = options.path ?? "/metrics";
10445
- const router = express2.Router();
13945
+ const router = express3.Router();
10446
13946
  if (options.guard) router.use(path, options.guard);
10447
13947
  router.get(path, async (_req, res) => {
10448
13948
  const gpus = options.includeGpu ? await MetricsUtils.gpus() : [];
@@ -10450,7 +13950,7 @@ function makeMetricsRouter(options = {}) {
10450
13950
  });
10451
13951
  return router;
10452
13952
  }
10453
- var logger3 = new JSONLogger("tempest_express_sdk.api.server");
13953
+ var logger4 = new JSONLogger("tempest_express_sdk.api.server");
10454
13954
  function corsMiddleware(origins) {
10455
13955
  const allowAll = origins === "*";
10456
13956
  const allowList = new Set(Array.isArray(origins) ? origins : [origins]);
@@ -10475,9 +13975,9 @@ function corsMiddleware(origins) {
10475
13975
  };
10476
13976
  }
10477
13977
  async function createApp(options = {}) {
10478
- const app = express2__default.default();
10479
- app.use(express2__default.default.json({ limit: options.jsonLimit ?? "100kb" }));
10480
- app.use(express2__default.default.urlencoded({ extended: true }));
13978
+ const app = express3__default.default();
13979
+ app.use(express3__default.default.json({ limit: options.jsonLimit ?? "100kb" }));
13980
+ app.use(express3__default.default.urlencoded({ extended: true }));
10481
13981
  app.use(requestIdMiddleware());
10482
13982
  if (options.corsOrigins) {
10483
13983
  app.use(corsMiddleware(options.corsOrigins));
@@ -10509,7 +14009,7 @@ function runServer(app, options = {}) {
10509
14009
  const port = options.port ?? 8e3;
10510
14010
  return new Promise((resolve) => {
10511
14011
  const server = app.listen(port, host, () => {
10512
- logger3.info("Server listening", { host, port });
14012
+ logger4.info("Server listening", { host, port });
10513
14013
  resolve(server);
10514
14014
  });
10515
14015
  });
@@ -11051,7 +14551,7 @@ function rateLimitMiddleware(options = {}) {
11051
14551
 
11052
14552
  // src/api/middlewares/tracing.ts
11053
14553
  function requestTracingMiddleware(options = {}) {
11054
- const logger4 = new JSONLogger(options.loggerName ?? "tempest_express_sdk.api.tracing");
14554
+ const logger5 = new JSONLogger(options.loggerName ?? "tempest_express_sdk.api.tracing");
11055
14555
  const level = options.level ?? "info";
11056
14556
  const exempt = new Set(options.exemptPaths ?? []);
11057
14557
  return (req, res, next) => {
@@ -11062,7 +14562,7 @@ function requestTracingMiddleware(options = {}) {
11062
14562
  const start = process.hrtime.bigint();
11063
14563
  res.on("finish", () => {
11064
14564
  const durationMs = Number(process.hrtime.bigint() - start) / 1e6;
11065
- logger4.log(level, "request", {
14565
+ logger5.log(level, "request", {
11066
14566
  method: req.method,
11067
14567
  path: req.path,
11068
14568
  statusCode: res.statusCode,
@@ -11351,7 +14851,7 @@ var WebhookSignatureVerifier = class {
11351
14851
  }
11352
14852
  };
11353
14853
  function makeToolSpecRouter(spec, options = {}) {
11354
- const router = express2.Router();
14854
+ const router = express3.Router();
11355
14855
  const path = options.path ?? "/tool-spec";
11356
14856
  router.get(path, (_req, res, next) => {
11357
14857
  Promise.resolve(typeof spec === "function" ? spec() : spec).then((result) => res.json(result)).catch(next);
@@ -11383,7 +14883,7 @@ async function readEntries(files) {
11383
14883
  return entries;
11384
14884
  }
11385
14885
  function makeLogsRouter(options) {
11386
- const router = express2.Router();
14886
+ const router = express3.Router();
11387
14887
  const path = options.path ?? "/logs";
11388
14888
  const guards = options.guards ?? [];
11389
14889
  const handler = (req, res, next) => {
@@ -11443,7 +14943,7 @@ async function withTestDatabase(models, fn) {
11443
14943
  }
11444
14944
 
11445
14945
  // src/version.ts
11446
- var VERSION = "0.23.0";
14946
+ var VERSION = "0.25.0";
11447
14947
 
11448
14948
  Object.defineProperty(exports, "OpenAPIRegistry", {
11449
14949
  enumerable: true,
@@ -11597,7 +15097,11 @@ Object.defineProperty(exports, "update", {
11597
15097
  enumerable: true,
11598
15098
  get: function () { return tempestDbJs.update; }
11599
15099
  });
15100
+ exports.ADMIN_CSS = ADMIN_CSS;
11600
15101
  exports.ActivationService = ActivationService;
15102
+ exports.AdminJsonSite = AdminJsonSite;
15103
+ exports.AdminModel = AdminModel;
15104
+ exports.AdminSessionStore = AdminSessionStore;
11601
15105
  exports.AdminSite = AdminSite;
11602
15106
  exports.AppException = AppException;
11603
15107
  exports.AttemptThrottle = AttemptThrottle;
@@ -11683,6 +15187,7 @@ exports.TwilioSmsProvider = TwilioSmsProvider;
11683
15187
  exports.UF = UF;
11684
15188
  exports.UnauthorizedException = UnauthorizedException;
11685
15189
  exports.UserAuthService = UserAuthService;
15190
+ exports.UserModelAuthBackend = UserModelAuthBackend;
11686
15191
  exports.UserTokenPurpose = UserTokenPurpose;
11687
15192
  exports.VERSION = VERSION;
11688
15193
  exports.ValidationException = ValidationException;
@@ -11694,6 +15199,9 @@ exports.WebhookSignatureVerifier = WebhookSignatureVerifier;
11694
15199
  exports.WhatsAppProvider = WhatsAppProvider;
11695
15200
  exports.activationSchema = activationSchema;
11696
15201
  exports.addLogSink = addLogSink;
15202
+ exports.adminAction = adminAction;
15203
+ exports.adminColumns = adminColumns;
15204
+ exports.adminThemeCss = adminThemeCss;
11697
15205
  exports.attachWebSocketHub = attachWebSocketHub;
11698
15206
  exports.authResponseSchema = authResponseSchema;
11699
15207
  exports.authSettingsShape = authSettingsShape;
@@ -11705,6 +15213,7 @@ exports.bearerToken = bearerToken;
11705
15213
  exports.bodySizeLimitMiddleware = bodySizeLimitMiddleware;
11706
15214
  exports.broadcastText = broadcastText;
11707
15215
  exports.buildContentDisposition = buildContentDisposition;
15216
+ exports.buildFormFields = buildFormFields;
11708
15217
  exports.buildPaginationLinkHeader = buildPaginationLinkHeader;
11709
15218
  exports.cached = cached3;
11710
15219
  exports.centsField = centsField;
@@ -11722,6 +15231,7 @@ exports.createOpenApiRegistry = createOpenApiRegistry;
11722
15231
  exports.createTestDatabase = createTestDatabase;
11723
15232
  exports.createdByColumn = createdByColumn;
11724
15233
  exports.csrfMiddleware = csrfMiddleware;
15234
+ exports.csrfTokenMatches = csrfTokenMatches;
11725
15235
  exports.cursorPaginationFilterSchema = cursorPaginationFilterSchema;
11726
15236
  exports.cursorPaginationSchema = cursorPaginationSchema;
11727
15237
  exports.databaseSettingsShape = databaseSettingsShape;
@@ -11734,6 +15244,13 @@ exports.emailSettingsShape = emailSettingsShape;
11734
15244
  exports.encodeCursor = encodeCursor;
11735
15245
  exports.envBoolean = looseBoolean;
11736
15246
  exports.envList = envList;
15247
+ exports.escapeHtml = escapeHtml;
15248
+ exports.filterForColumn = filterForColumn;
15249
+ exports.foreignKeyFields = foreignKeyFields;
15250
+ exports.foreignKeyLabel = foreignKeyLabel;
15251
+ exports.foreignKeyTable = foreignKeyTable;
15252
+ exports.formatCellValue = formatCellValue;
15253
+ exports.formatFieldValue = formatFieldValue;
11737
15254
  exports.generateCsrfToken = generateCsrfToken;
11738
15255
  exports.generateOAuthState = generateOAuthState;
11739
15256
  exports.generateOpaqueToken = generateOpaqueToken;
@@ -11746,8 +15263,11 @@ exports.getRequestId = getRequestId;
11746
15263
  exports.getState = getState;
11747
15264
  exports.hashOpaqueToken = hashOpaqueToken;
11748
15265
  exports.hexColorField = hexColorField;
15266
+ exports.humanizeField = humanizeField;
11749
15267
  exports.idempotencyMiddleware = idempotencyMiddleware;
11750
15268
  exports.inboundMessageSchema = inboundMessageSchema;
15269
+ exports.isColumnOptional = isColumnOptional;
15270
+ exports.isSearchableColumn = isSearchableColumn;
11751
15271
  exports.isValidCep = isValidCep;
11752
15272
  exports.isValidCity = isValidCity;
11753
15273
  exports.isValidCnpj = isValidCnpj;
@@ -11768,6 +15288,7 @@ exports.logSettingsShape = logSettingsShape;
11768
15288
  exports.loginSchema = loginSchema;
11769
15289
  exports.longitudeField = longitudeField;
11770
15290
  exports.looseBoolean = looseBoolean;
15291
+ exports.makeAdminJsonRouter = makeAdminJsonRouter;
11771
15292
  exports.makeAdminRouter = makeAdminRouter;
11772
15293
  exports.makeAppExceptionHandler = makeAppExceptionHandler;
11773
15294
  exports.makeAuthRouter = makeAuthRouter;
@@ -11804,6 +15325,7 @@ exports.paginationFilterSchema = paginationFilterSchema;
11804
15325
  exports.paginationSchema = paginationSchema;
11805
15326
  exports.parseAcceptLanguage = parseAcceptLanguage;
11806
15327
  exports.parseCookies = parseCookies;
15328
+ exports.parseFormBody = parseFormBody;
11807
15329
  exports.passwordResetConfirmSchema = passwordResetConfirmSchema;
11808
15330
  exports.passwordResetRequestSchema = passwordResetRequestSchema;
11809
15331
  exports.percentField = percentField;
@@ -11821,10 +15343,18 @@ exports.redisSettingsShape = redisSettingsShape;
11821
15343
  exports.refreshSchema = refreshSchema;
11822
15344
  exports.registerExceptionHandlers = registerExceptionHandlers;
11823
15345
  exports.renderAuthResultPage = renderAuthResultPage;
15346
+ exports.renderDashboardPage = renderDashboardPage;
15347
+ exports.renderDetailPage = renderDetailPage;
15348
+ exports.renderFormPage = renderFormPage;
15349
+ exports.renderLayout = renderLayout;
15350
+ exports.renderListPage = renderListPage;
15351
+ exports.renderLoginPage = renderLoginPage;
15352
+ exports.renderMfaPage = renderMfaPage;
11824
15353
  exports.renderPasswordResetFormPage = renderPasswordResetFormPage;
11825
15354
  exports.requestIdMiddleware = requestIdMiddleware;
11826
15355
  exports.requestTracingMiddleware = requestTracingMiddleware;
11827
15356
  exports.requireRoles = requireRoles;
15357
+ exports.resolveAdminTheme = resolveAdminTheme;
11828
15358
  exports.resolveDownloadPath = resolveDownloadPath;
11829
15359
  exports.resolveRedocBundle = resolveRedocBundle;
11830
15360
  exports.runServer = runServer;
@@ -11860,6 +15390,7 @@ exports.webPushPayloadSchema = webPushPayloadSchema;
11860
15390
  exports.webPushSettingsShape = webPushSettingsShape;
11861
15391
  exports.webPushSubscriptionSchema = webPushSubscriptionSchema;
11862
15392
  exports.webSocketSettingsShape = webSocketSettingsShape;
15393
+ exports.widgetForColumn = widgetForColumn;
11863
15394
  exports.withTestDatabase = withTestDatabase;
11864
15395
  exports.wrapWithSlowQueryLog = wrapWithSlowQueryLog;
11865
15396
  exports.wsEnvelopeSchema = wsEnvelopeSchema;