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