tina4-nodejs 3.13.103 → 3.13.105

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.
Files changed (33) hide show
  1. package/CLAUDE.md +16 -2
  2. package/package.json +1 -1
  3. package/packages/cli/dist/bin.js +747 -57
  4. package/packages/core/dist/index.js +750 -57
  5. package/packages/core/src/authGate.ts +6 -0
  6. package/packages/core/src/index.ts +2 -0
  7. package/packages/core/src/queue.ts +75 -18
  8. package/packages/core/src/queueBackends/liteBackend.ts +17 -1
  9. package/packages/core/src/queueBackends/mongoBackend.ts +80 -14
  10. package/packages/core/src/server.ts +5 -0
  11. package/packages/core/src/sso.ts +285 -0
  12. package/packages/orm/dist/index.js +755 -62
  13. package/packages/orm/src/adapters/postgres.ts +2 -0
  14. package/packages/orm/src/baseModel.ts +63 -10
  15. package/packages/orm/src/index.ts +2 -0
  16. package/packages/orm/src/migration.ts +14 -5
  17. package/packages/orm/src/point.ts +105 -0
  18. package/packages/orm/src/queryBuilder.ts +66 -5
  19. package/packages/orm/src/sqlTranslator.ts +63 -0
  20. package/packages/orm/src/types.ts +5 -1
  21. package/packages/swagger/dist/index.js +11 -1
  22. package/packages/swagger/src/generator.ts +11 -1
  23. package/types/core/src/index.d.ts +2 -0
  24. package/types/core/src/queue.d.ts +41 -4
  25. package/types/core/src/queueBackends/liteBackend.d.ts +7 -1
  26. package/types/core/src/queueBackends/mongoBackend.d.ts +7 -2
  27. package/types/core/src/sso.d.ts +55 -0
  28. package/types/orm/src/baseModel.d.ts +13 -5
  29. package/types/orm/src/index.d.ts +2 -0
  30. package/types/orm/src/point.d.ts +24 -0
  31. package/types/orm/src/queryBuilder.d.ts +10 -1
  32. package/types/orm/src/sqlTranslator.d.ts +13 -0
  33. package/types/orm/src/types.d.ts +5 -1
@@ -6015,13 +6015,172 @@ var init_databaseUrl = __esm({
6015
6015
  }
6016
6016
  });
6017
6017
 
6018
+ // ../orm/src/point.ts
6019
+ function formatCoordinate(value) {
6020
+ return Object.is(value, -0) ? "0" : Number(value.toPrecision(15)).toString();
6021
+ }
6022
+ var DEFAULT_SRID, SpatialNotSupportedError, Point;
6023
+ var init_point = __esm({
6024
+ "../orm/src/point.ts"() {
6025
+ "use strict";
6026
+ DEFAULT_SRID = 4326;
6027
+ SpatialNotSupportedError = class extends Error {
6028
+ constructor(message) {
6029
+ super(message);
6030
+ this.name = "SpatialNotSupportedError";
6031
+ }
6032
+ };
6033
+ Point = class _Point {
6034
+ lon;
6035
+ lat;
6036
+ srid;
6037
+ constructor(lon, lat, srid = DEFAULT_SRID) {
6038
+ if (typeof lon === "boolean" || typeof lat === "boolean" || typeof srid === "boolean") {
6039
+ throw new TypeError("Point longitude, latitude and SRID must be numbers");
6040
+ }
6041
+ this.lon = Number(lon);
6042
+ this.lat = Number(lat);
6043
+ this.srid = Number(srid);
6044
+ if (!Number.isFinite(this.lon) || !Number.isFinite(this.lat) || !Number.isInteger(this.srid)) {
6045
+ throw new TypeError("Point longitude and latitude must be finite numbers and SRID must be an integer");
6046
+ }
6047
+ if (this.srid === DEFAULT_SRID) {
6048
+ if (this.lon < -180 || this.lon > 180) throw new RangeError(`Point longitude ${this.lon} is outside -180..180; Tina4 uses longitude, latitude order`);
6049
+ if (this.lat < -90 || this.lat > 90) throw new RangeError(`Point latitude ${this.lat} is outside -90..90; Tina4 uses longitude, latitude order`);
6050
+ }
6051
+ Object.freeze(this);
6052
+ }
6053
+ get wkt() {
6054
+ return `POINT(${formatCoordinate(this.lon)} ${formatCoordinate(this.lat)})`;
6055
+ }
6056
+ get ewkt() {
6057
+ return `SRID=${this.srid};${this.wkt}`;
6058
+ }
6059
+ get geojson() {
6060
+ return { type: "Point", coordinates: [this.lon, this.lat] };
6061
+ }
6062
+ toJSON() {
6063
+ return this.geojson;
6064
+ }
6065
+ toArray() {
6066
+ return [this.lon, this.lat];
6067
+ }
6068
+ static parse(value, srid = DEFAULT_SRID) {
6069
+ if (value instanceof _Point) return value;
6070
+ if (Array.isArray(value)) {
6071
+ if (value.length < 2) throw new TypeError("Point coordinate pair needs longitude and latitude");
6072
+ return new _Point(value[0], value[1], srid);
6073
+ }
6074
+ if (value && typeof value === "object" && !(value instanceof Uint8Array)) {
6075
+ return _Point.fromGeoJson(value, srid);
6076
+ }
6077
+ if (value instanceof Uint8Array) return _Point.fromWkb(value, srid);
6078
+ if (typeof value === "string") {
6079
+ const text = value.trim();
6080
+ const match = /^(?:SRID\s*=\s*(\d+)\s*;\s*)?POINT\s*(?:Z|M|ZM)?\s*\(\s*([-+0-9.eE]+)\s+([-+0-9.eE]+)(?:\s+[-+0-9.eE]+)*\s*\)$/i.exec(text);
6081
+ if (match) return new _Point(match[2], match[3], match[1] ? Number(match[1]) : srid);
6082
+ if (text.length >= 42 && text.length % 2 === 0 && /^[0-9a-f]+$/i.test(text)) {
6083
+ return _Point.fromWkb(Uint8Array.from(Buffer.from(text, "hex")), srid);
6084
+ }
6085
+ }
6086
+ throw new TypeError("Point must be Point, [longitude, latitude], WKT/EWKT, GeoJSON or WKB/EWKB");
6087
+ }
6088
+ static geometryBinding(value, srid = DEFAULT_SRID) {
6089
+ if (value instanceof _Point || Array.isArray(value)) return [_Point.parse(value, srid).ewkt, "ewkt"];
6090
+ if (value && typeof value === "object") {
6091
+ const candidate = value;
6092
+ const geometry = String(candidate.type).toLowerCase() === "feature" ? candidate.geometry : candidate;
6093
+ const allowed = /* @__PURE__ */ new Set(["point", "linestring", "polygon", "multipoint", "multilinestring", "multipolygon", "geometrycollection"]);
6094
+ if (!geometry || !allowed.has(String(geometry.type).toLowerCase())) throw new TypeError("GeoJSON geometry has an unsupported type");
6095
+ return [JSON.stringify(geometry), "geojson"];
6096
+ }
6097
+ if (typeof value === "string" && /^\s*(?:SRID\s*=\s*\d+\s*;\s*)?(?:POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON|GEOMETRYCOLLECTION)\b/i.test(value)) {
6098
+ return [/^\s*SRID/i.test(value) ? value.trim() : `SRID=${srid};${value.trim()}`, "ewkt"];
6099
+ }
6100
+ throw new TypeError("Geometry must be Point, coordinate pair, WKT/EWKT or GeoJSON");
6101
+ }
6102
+ static fromGeoJson(data, srid) {
6103
+ const geometry = String(data.type).toLowerCase() === "feature" ? data.geometry : data;
6104
+ if (!geometry || String(geometry.type).toLowerCase() !== "point") throw new TypeError("Point GeoJSON type must be Point");
6105
+ const coordinates = geometry.coordinates;
6106
+ if (!Array.isArray(coordinates) || coordinates.length < 2) throw new TypeError("Point GeoJSON coordinates must be [longitude, latitude]");
6107
+ return new _Point(coordinates[0], coordinates[1], srid);
6108
+ }
6109
+ static fromWkb(raw, srid) {
6110
+ if (raw.byteLength < 21) throw new TypeError("Point WKB is too short");
6111
+ const view = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
6112
+ const little = raw[0] === 1;
6113
+ const typeWord = view.getUint32(1, little);
6114
+ let offset = 5;
6115
+ if ((typeWord & 536870912) !== 0) {
6116
+ srid = view.getUint32(5, little);
6117
+ offset = 9;
6118
+ }
6119
+ const code = (typeWord & ~(536870912 | 1073741824 | 2147483648)) % 1e3;
6120
+ if (code !== 1 || raw.byteLength < offset + 16) throw new TypeError("WKB geometry is not a Point");
6121
+ return new _Point(view.getFloat64(offset, little), view.getFloat64(offset + 8, little), srid);
6122
+ }
6123
+ };
6124
+ }
6125
+ });
6126
+
6018
6127
  // ../orm/src/sqlTranslator.ts
6019
6128
  var SQLTranslator, QueryCache;
6020
6129
  var init_sqlTranslator = __esm({
6021
6130
  "../orm/src/sqlTranslator.ts"() {
6022
6131
  "use strict";
6023
6132
  init_databaseUrl();
6133
+ init_point();
6024
6134
  SQLTranslator = class _SQLTranslator {
6135
+ static SPATIAL_ENGINES = /* @__PURE__ */ new Set(["postgres", "postgresql"]);
6136
+ static SPATIAL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
6137
+ static requireSpatial(engine, feature) {
6138
+ const name = String(engine || "unknown").toLowerCase();
6139
+ if (!_SQLTranslator.SPATIAL_ENGINES.has(name)) {
6140
+ throw new SpatialNotSupportedError(
6141
+ `${feature} is not supported on the '${name}' database engine. Tina4 GIS support is PostGIS-first: use PostgreSQL with CREATE EXTENSION postgis. Tina4 will not replace a spatial query with an approximate coordinate query.`
6142
+ );
6143
+ }
6144
+ return name;
6145
+ }
6146
+ static spatialIdentifier(name, what = "column") {
6147
+ if (!_SQLTranslator.SPATIAL_IDENTIFIER.test(name)) throw new TypeError(`Spatial ${what} is not a valid SQL identifier: ${name}`);
6148
+ return name;
6149
+ }
6150
+ static pointColumnType(engine, srid = DEFAULT_SRID) {
6151
+ _SQLTranslator.requireSpatial(engine, "PointField");
6152
+ return `geography(Point,${srid})`;
6153
+ }
6154
+ static spatialIndex(engine, table2, column2) {
6155
+ _SQLTranslator.requireSpatial(engine, "spatial index creation");
6156
+ table2 = _SQLTranslator.spatialIdentifier(table2, "table");
6157
+ column2 = _SQLTranslator.spatialIdentifier(column2);
6158
+ return `CREATE INDEX IF NOT EXISTS ${table2.replaceAll(".", "_")}_${column2}_gist ON ${table2} USING GIST (${column2})`;
6159
+ }
6160
+ static pointLiteral(engine, srid = DEFAULT_SRID) {
6161
+ _SQLTranslator.requireSpatial(engine, "spatial predicates");
6162
+ return `ST_SetSRID(ST_MakePoint(?, ?), ${srid})::geography`;
6163
+ }
6164
+ static withinDistance(engine, column2, srid = DEFAULT_SRID) {
6165
+ return `ST_DWithin(${_SQLTranslator.spatialIdentifier(column2)}, ${_SQLTranslator.pointLiteral(engine, srid)}, ?)`;
6166
+ }
6167
+ static distance(engine, column2, srid = DEFAULT_SRID) {
6168
+ return `ST_Distance(${_SQLTranslator.spatialIdentifier(column2)}, ${_SQLTranslator.pointLiteral(engine, srid)})`;
6169
+ }
6170
+ static distanceAs(engine, column2, alias, srid = DEFAULT_SRID) {
6171
+ return `${_SQLTranslator.distance(engine, column2, srid)} AS ${_SQLTranslator.spatialIdentifier(alias, "result alias")}`;
6172
+ }
6173
+ static geometryLiteral(engine, form, srid = DEFAULT_SRID) {
6174
+ _SQLTranslator.requireSpatial(engine, "spatial predicates");
6175
+ return form === "ewkt" ? "ST_GeogFromText(?)" : `ST_SetSRID(ST_GeomFromGeoJSON(?), ${srid})::geography`;
6176
+ }
6177
+ static intersects(engine, column2, form = "ewkt", srid = DEFAULT_SRID) {
6178
+ return `ST_Intersects(${_SQLTranslator.spatialIdentifier(column2)}, ${_SQLTranslator.geometryLiteral(engine, form, srid)})`;
6179
+ }
6180
+ static bbox(engine, column2, srid = DEFAULT_SRID) {
6181
+ _SQLTranslator.requireSpatial(engine, "bbox");
6182
+ return `ST_Intersects(${_SQLTranslator.spatialIdentifier(column2)}, ST_MakeEnvelope(?, ?, ?, ?, ${srid})::geography)`;
6183
+ }
6025
6184
  /**
6026
6185
  * Convert LIMIT/OFFSET to Firebird ROWS...TO syntax.
6027
6186
  *
@@ -7675,6 +7834,8 @@ function fieldTypeToPostgres(def) {
7675
7834
  return "TEXT";
7676
7835
  case "json":
7677
7836
  return "JSONB";
7837
+ case "point":
7838
+ return SQLTranslator.pointColumnType("postgres", def.srid ?? 4326);
7678
7839
  case "string":
7679
7840
  return def.maxLength ? `VARCHAR(${def.maxLength})` : "VARCHAR(255)";
7680
7841
  default:
@@ -12191,7 +12352,9 @@ function buildAddColumnSql(adapter, table2, colName, def) {
12191
12352
  return sql;
12192
12353
  }
12193
12354
  function mt(db) {
12194
- return engineOf(db) === "mysql" ? `\`${MIGRATION_TABLE}\`` : `"${MIGRATION_TABLE}"`;
12355
+ const engine = engineOf(db);
12356
+ if (engine === "firebird") return MIGRATION_TABLE;
12357
+ return engine === "mysql" ? `\`${MIGRATION_TABLE}\`` : `"${MIGRATION_TABLE}"`;
12195
12358
  }
12196
12359
  function deriveDescription(name) {
12197
12360
  return name.replace(/^\d+_/, "").replace(/_/g, " ");
@@ -12214,9 +12377,9 @@ async function ensureMigrationTableOn(db) {
12214
12377
  id INTEGER NOT NULL PRIMARY KEY,
12215
12378
  migration_name VARCHAR(500) NOT NULL UNIQUE,
12216
12379
  description VARCHAR(500),
12217
- batch INTEGER NOT NULL DEFAULT 1,
12380
+ batch INTEGER DEFAULT 1 NOT NULL,
12218
12381
  executed_at VARCHAR(50) NOT NULL,
12219
- passed INTEGER NOT NULL DEFAULT 1
12382
+ passed INTEGER DEFAULT 1 NOT NULL
12220
12383
  )`);
12221
12384
  } else {
12222
12385
  const idCol = migrationIdColumn(db);
@@ -12313,7 +12476,7 @@ async function recordApplied(db, name, batch, passed = 1) {
12313
12476
  "SELECT GEN_ID(GEN_TINA4_MIGRATION_ID, 1) AS NEXT_ID FROM RDB$DATABASE"
12314
12477
  );
12315
12478
  insertCols.unshift("id");
12316
- values.unshift(rows[0]?.NEXT_ID ?? 1);
12479
+ values.unshift(rows[0]?.next_id ?? 1);
12317
12480
  }
12318
12481
  const placeholders = insertCols.map(() => "?").join(", ");
12319
12482
  await adapterExecute(
@@ -13298,10 +13461,13 @@ var init_queryBuilder = __esm({
13298
13461
  "use strict";
13299
13462
  init_database();
13300
13463
  init_databaseResult();
13464
+ init_point();
13465
+ init_sqlTranslator();
13301
13466
  QueryBuilder = class _QueryBuilder {
13302
13467
  table;
13303
13468
  db;
13304
13469
  columns = ["*"];
13470
+ selectParams = [];
13305
13471
  wheres = [];
13306
13472
  params = [];
13307
13473
  joinClauses = [];
@@ -13309,14 +13475,17 @@ var init_queryBuilder = __esm({
13309
13475
  havings = [];
13310
13476
  havingParams = [];
13311
13477
  orderByCols = [];
13478
+ orderByParams = [];
13479
+ primaryKey;
13312
13480
  limitVal;
13313
13481
  offsetVal;
13314
13482
  /**
13315
13483
  * Private constructor — use static factory methods.
13316
13484
  */
13317
- constructor(table2, db) {
13485
+ constructor(table2, db, primaryKey) {
13318
13486
  this.table = table2;
13319
13487
  this.db = db;
13488
+ this.primaryKey = primaryKey;
13320
13489
  }
13321
13490
  /**
13322
13491
  * Create a QueryBuilder for a table.
@@ -13325,8 +13494,8 @@ var init_queryBuilder = __esm({
13325
13494
  * @param db - Optional database adapter.
13326
13495
  * @returns A new QueryBuilder instance.
13327
13496
  */
13328
- static fromTable(tableName, db) {
13329
- return new _QueryBuilder(tableName, db);
13497
+ static fromTable(tableName, db, primaryKey) {
13498
+ return new _QueryBuilder(tableName, db, primaryKey);
13330
13499
  }
13331
13500
  /**
13332
13501
  * Set the columns to select.
@@ -13337,6 +13506,7 @@ var init_queryBuilder = __esm({
13337
13506
  select(...cols) {
13338
13507
  if (cols.length > 0) {
13339
13508
  this.columns = cols;
13509
+ this.selectParams = [];
13340
13510
  }
13341
13511
  return this;
13342
13512
  }
@@ -13418,6 +13588,41 @@ var init_queryBuilder = __esm({
13418
13588
  this.orderByCols.push(expression);
13419
13589
  return this;
13420
13590
  }
13591
+ withinDistance(column2, pointValue, radiusMetres, srid = DEFAULT_SRID) {
13592
+ const radius = Number(radiusMetres);
13593
+ if (!Number.isFinite(radius) || radius < 0) throw new RangeError("Spatial radius must be finite and greater than or equal to zero");
13594
+ const point = Point.parse(pointValue, srid);
13595
+ return this.where(SQLTranslator.withinDistance(this.engine(), column2, point.srid), [point.lon, point.lat, radius]);
13596
+ }
13597
+ intersects(column2, geometry, srid = DEFAULT_SRID) {
13598
+ const [bound, form] = Point.geometryBinding(geometry, srid);
13599
+ return this.where(SQLTranslator.intersects(this.engine(), column2, form, srid), [bound]);
13600
+ }
13601
+ bbox(column2, minLon, minLat, maxLon, maxLat, srid = DEFAULT_SRID) {
13602
+ const values = [minLon, minLat, maxLon, maxLat].map(Number);
13603
+ if (!values.every(Number.isFinite)) throw new TypeError("Bounding-box coordinates must be finite numbers");
13604
+ const [west, south, east, north] = values;
13605
+ new Point(west, south, srid);
13606
+ new Point(east, north, srid);
13607
+ if (west > east || south > north) throw new RangeError("Bounding box must be ordered west, south, east, north");
13608
+ return this.where(SQLTranslator.bbox(this.engine(), column2, srid), values);
13609
+ }
13610
+ selectDistance(column2, pointValue, alias = "distance", srid = DEFAULT_SRID) {
13611
+ const point = Point.parse(pointValue, srid);
13612
+ this.columns.push(SQLTranslator.distanceAs(this.engine(), column2, alias, point.srid));
13613
+ this.selectParams.push(point.lon, point.lat);
13614
+ return this;
13615
+ }
13616
+ orderByDistance(column2, pointValue, direction = "ASC", srid = DEFAULT_SRID) {
13617
+ const order = direction.toUpperCase();
13618
+ if (order !== "ASC" && order !== "DESC") throw new TypeError("Distance order direction must be ASC or DESC");
13619
+ if (!this.primaryKey) throw new Error("Stable spatial ordering needs a primary key; use BaseModel.query() or pass one to fromTable()");
13620
+ const point = Point.parse(pointValue, srid);
13621
+ this.orderByCols.push(`${SQLTranslator.distance(this.engine(), column2, point.srid)} ${order}`);
13622
+ this.orderByParams.push(point.lon, point.lat);
13623
+ this.orderByCols.push(`${SQLTranslator.spatialIdentifier(this.primaryKey, "primary key")} ASC`);
13624
+ return this;
13625
+ }
13421
13626
  /**
13422
13627
  * Set LIMIT and optional OFFSET.
13423
13628
  *
@@ -13483,7 +13688,7 @@ var init_queryBuilder = __esm({
13483
13688
  async get() {
13484
13689
  this.ensureDb();
13485
13690
  const sql = this.toSql();
13486
- const allParams = [...this.params, ...this.havingParams];
13691
+ const allParams = [...this.selectParams, ...this.params, ...this.havingParams, ...this.orderByParams];
13487
13692
  const queryParams = allParams.length > 0 ? allParams : void 0;
13488
13693
  const rows = await adapterFetch(
13489
13694
  this.db,
@@ -13511,7 +13716,7 @@ var init_queryBuilder = __esm({
13511
13716
  async first() {
13512
13717
  this.ensureDb();
13513
13718
  const sql = this.toSql();
13514
- const allParams = [...this.params, ...this.havingParams];
13719
+ const allParams = [...this.selectParams, ...this.params, ...this.havingParams, ...this.orderByParams];
13515
13720
  return adapterFetchOne(
13516
13721
  this.db,
13517
13722
  sql,
@@ -13526,9 +13731,18 @@ var init_queryBuilder = __esm({
13526
13731
  async count() {
13527
13732
  this.ensureDb();
13528
13733
  const original = this.columns;
13734
+ const originalSelectParams = this.selectParams;
13735
+ const originalOrder = this.orderByCols;
13736
+ const originalOrderParams = this.orderByParams;
13529
13737
  this.columns = ["COUNT(*) as cnt"];
13738
+ this.selectParams = [];
13739
+ this.orderByCols = [];
13740
+ this.orderByParams = [];
13530
13741
  const sql = this.toSql();
13531
13742
  this.columns = original;
13743
+ this.selectParams = originalSelectParams;
13744
+ this.orderByCols = originalOrder;
13745
+ this.orderByParams = originalOrderParams;
13532
13746
  const allParams = [...this.params, ...this.havingParams];
13533
13747
  const row = await adapterFetchOne(
13534
13748
  this.db,
@@ -13713,6 +13927,10 @@ var init_queryBuilder = __esm({
13713
13927
  }
13714
13928
  }
13715
13929
  }
13930
+ engine() {
13931
+ this.ensureDb();
13932
+ return this.db.getDatabaseType();
13933
+ }
13716
13934
  };
13717
13935
  }
13718
13936
  });
@@ -13736,6 +13954,11 @@ function toDbFieldValue(def, value) {
13736
13954
  if (def?.type === "json" && value !== null && value !== void 0 && typeof value !== "string") {
13737
13955
  return JSON.stringify(value);
13738
13956
  }
13957
+ if (def?.type === "point" && value !== null && value !== void 0) {
13958
+ const point = Point.parse(value, def.srid ?? DEFAULT_SRID);
13959
+ if (point.srid !== (def.srid ?? DEFAULT_SRID)) throw new TypeError(`Point field expects SRID ${def.srid ?? DEFAULT_SRID}; received ${point.srid}`);
13960
+ return point.ewkt;
13961
+ }
13739
13962
  return value;
13740
13963
  }
13741
13964
  function fromDbFieldValue(def, value) {
@@ -13746,6 +13969,11 @@ function fromDbFieldValue(def, value) {
13746
13969
  return value;
13747
13970
  }
13748
13971
  }
13972
+ if (def?.type === "point" && value !== null && value !== void 0) {
13973
+ const point = Point.parse(value, def.srid ?? DEFAULT_SRID);
13974
+ if (point.srid !== (def.srid ?? DEFAULT_SRID)) throw new TypeError(`Point field expects SRID ${def.srid ?? DEFAULT_SRID}; received ${point.srid}`);
13975
+ return point;
13976
+ }
13749
13977
  return value;
13750
13978
  }
13751
13979
  function _pluralRelKeys() {
@@ -13781,6 +14009,7 @@ var init_baseModel = __esm({
13781
14009
  init_sqlite();
13782
14010
  init_sqlTranslator();
13783
14011
  init_src3();
14012
+ init_point();
13784
14013
  _fkRegistry = /* @__PURE__ */ new Map();
13785
14014
  EAGER_IN_CHUNK = 500;
13786
14015
  modelQueryCache = new QueryCache({ defaultTtl: 0, maxSize: 500 });
@@ -13836,7 +14065,9 @@ var init_baseModel = __esm({
13836
14065
  for (const [name, def] of Object.entries(fields0)) {
13837
14066
  if (def.default === void 0) continue;
13838
14067
  let dv = typeof def.default === "function" ? def.default() : def.default;
13839
- if (dv !== null && typeof dv === "object") dv = structuredClone(dv);
14068
+ if (def.type === "point" && dv !== null && dv !== void 0) {
14069
+ dv = fromDbFieldValue(def, dv);
14070
+ } else if (dv !== null && typeof dv === "object") dv = structuredClone(dv);
13840
14071
  this[name] = dv;
13841
14072
  }
13842
14073
  if (data) {
@@ -13938,7 +14169,7 @@ var init_baseModel = __esm({
13938
14169
  * @returns A QueryBuilder instance bound to this model's table and database.
13939
14170
  */
13940
14171
  static query() {
13941
- return QueryBuilder.fromTable(this.tableName, this.getDb());
14172
+ return QueryBuilder.fromTable(this.tableName, this.getDb(), this.getPkColumn());
13942
14173
  }
13943
14174
  /**
13944
14175
  * Get the database adapter for this model.
@@ -14391,7 +14622,7 @@ var init_baseModel = __esm({
14391
14622
  for (const key of Object.keys(ModelClass.fields)) {
14392
14623
  if (this[key] !== void 0) {
14393
14624
  const outKey = case_ === "snake" ? ModelClass.fieldMapping[key] ?? key : key;
14394
- result[outKey] = this[key];
14625
+ result[outKey] = this[key] instanceof Point ? this[key].geojson : this[key];
14395
14626
  }
14396
14627
  }
14397
14628
  if (ModelClass.softDelete && this.is_deleted !== void 0) {
@@ -14451,6 +14682,19 @@ var init_baseModel = __esm({
14451
14682
  }
14452
14683
  return result;
14453
14684
  }
14685
+ toFeature(geometryField, include) {
14686
+ const ModelClass = this.constructor;
14687
+ const pointFields = Object.entries(ModelClass.fields).filter(([, def]) => def.type === "point").map(([name]) => name);
14688
+ const field = geometryField ?? pointFields[0];
14689
+ if (!field || !pointFields.includes(field)) throw new Error("toFeature() needs a declared point field");
14690
+ const properties = this.toDict(include, "camel");
14691
+ const geometry = properties[field] ?? null;
14692
+ delete properties[field];
14693
+ return { type: "Feature", geometry, properties };
14694
+ }
14695
+ static featureCollection(models, geometryField, include) {
14696
+ return { type: "FeatureCollection", features: models.map((model) => model.toFeature(geometryField, include)) };
14697
+ }
14454
14698
  /**
14455
14699
  * Convert to an associative object (alias for toDict).
14456
14700
  */
@@ -14501,7 +14745,10 @@ var init_baseModel = __esm({
14501
14745
  */
14502
14746
  static async createTable() {
14503
14747
  const db = this.getDb();
14504
- if (await adapterTableExists(db, this.tableName)) return true;
14748
+ const pointFields = Object.entries(this.fields).filter(([, def]) => def.type === "point");
14749
+ const engine = db.getDatabaseType();
14750
+ if (pointFields.length > 0) SQLTranslator.requireSpatial(engine, "PointField");
14751
+ if (await adapterTableExists(db, this.tableName)) return this.createSpatialIndexes(db, pointFields);
14505
14752
  if (typeof db.createTable === "function" || typeof db.createTableAsync === "function") {
14506
14753
  const mappedFields = {};
14507
14754
  for (const [fieldName, def] of Object.entries(this.fields)) {
@@ -14517,7 +14764,7 @@ var init_baseModel = __esm({
14517
14764
  mappedFields["is_deleted"] = { type: "integer", default: 0 };
14518
14765
  }
14519
14766
  await adapterCreateTable(db, this.tableName, mappedFields);
14520
- return true;
14767
+ return this.createSpatialIndexes(db, pointFields);
14521
14768
  }
14522
14769
  const typeMap = {
14523
14770
  integer: "INTEGER",
@@ -14564,6 +14811,14 @@ var init_baseModel = __esm({
14564
14811
  }
14565
14812
  return true;
14566
14813
  }
14814
+ static async createSpatialIndexes(db, fields) {
14815
+ for (const [fieldName, def] of fields) {
14816
+ SQLTranslator.pointColumnType(db.getDatabaseType(), def.srid ?? DEFAULT_SRID);
14817
+ if (def.spatialIndex === false) continue;
14818
+ await adapterExecute(db, SQLTranslator.spatialIndex(db.getDatabaseType(), this.tableName, this.getDbColumn(fieldName)));
14819
+ }
14820
+ return true;
14821
+ }
14567
14822
  /**
14568
14823
  * Find a record by primary key or throw an error if not found.
14569
14824
  */
@@ -14635,15 +14890,25 @@ var init_baseModel = __esm({
14635
14890
  /**
14636
14891
  * Invalidate every cached query that touches this model's table.
14637
14892
  *
14638
- * Tag-scoped, NOT a wholesale flush: a cached JOIN on another model that reads
14639
- * this table is busted too (it carries this table's tag), while a query that
14640
- * never touches this table is left intact. Called after every ORM write
14641
- * (save/delete/forceDelete/restore) so a read-after-write never serves a
14642
- * stale/deleted row (CACHE-DEC-01).
14893
+ * Tag-scoped in the ORM layer (a cached JOIN on another model that reads
14894
+ * this table is busted too because it carries this table's tag; a query
14895
+ * that never touches this table is left intact), then cascaded to the
14896
+ * DB layer on this model's bound connection so an out-of-band write /
14897
+ * deliberate refresh / race-with-another-process cannot leave stale rows
14898
+ * in db.fetch()'s persistent cache. Called after every ORM write
14899
+ * (save/delete/forceDelete/restore) so a read-after-write never serves
14900
+ * a stale/deleted row (CACHE-DEC-01). PY-06-22 (3.13.105) added the
14901
+ * DB-layer cascade -- previously the two cache layers disagreed under
14902
+ * TINA4_AUTO_CACHING=true + TINA4_DB_CACHE=true.
14643
14903
  */
14644
14904
  static clearCache() {
14645
14905
  const ModelClass = this;
14646
14906
  modelQueryCache.clearTag((ModelClass.tableName ?? "").toLowerCase());
14907
+ try {
14908
+ const db = ModelClass.getDb();
14909
+ if (typeof db?.cacheClear === "function") db.cacheClear();
14910
+ } catch {
14911
+ }
14647
14912
  }
14648
14913
  /**
14649
14914
  * Execute a raw SQL SELECT and return results as model instances.
@@ -17211,6 +17476,7 @@ __export(src_exports, {
17211
17476
  CachedDatabaseAdapter: () => CachedDatabaseAdapter,
17212
17477
  Cursor: () => Cursor,
17213
17478
  DEFAULT_DATABASE_CONNECT_TIMEOUT_SECONDS: () => DEFAULT_DATABASE_CONNECT_TIMEOUT_SECONDS,
17479
+ DEFAULT_SRID: () => DEFAULT_SRID,
17214
17480
  Database: () => Database,
17215
17481
  DatabaseResult: () => DatabaseResult,
17216
17482
  DatabaseUrl: () => DatabaseUrl,
@@ -17226,6 +17492,7 @@ __export(src_exports, {
17226
17492
  NOT_REQUIRED_ON_ADAPTER: () => NOT_REQUIRED_ON_ADAPTER,
17227
17493
  ObjectId: () => ObjectId,
17228
17494
  OdbcAdapter: () => OdbcAdapter,
17495
+ Point: () => Point,
17229
17496
  PostgresAdapter: () => PostgresAdapter,
17230
17497
  QueryBuilder: () => QueryBuilder,
17231
17498
  QueryCache: () => QueryCache,
@@ -17238,6 +17505,7 @@ __export(src_exports, {
17238
17505
  S3Storage: () => S3Storage,
17239
17506
  SQLTranslator: () => SQLTranslator,
17240
17507
  SQLiteAdapter: () => SQLiteAdapter,
17508
+ SpatialNotSupportedError: () => SpatialNotSupportedError,
17241
17509
  SqliteCollection: () => SqliteCollection,
17242
17510
  SqliteDatabase: () => SqliteDatabase,
17243
17511
  adapterColumns: () => adapterColumns,
@@ -17332,6 +17600,7 @@ var init_src = __esm({
17332
17600
  init_baseModel();
17333
17601
  init_queryBuilder();
17334
17602
  init_sqlTranslator();
17603
+ init_point();
17335
17604
  init_connectTimeout();
17336
17605
  init_cachedDatabase();
17337
17606
  init_fakeData2();
@@ -19521,6 +19790,12 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
19521
19790
  }
19522
19791
  }
19523
19792
  if (!resolvedToken) {
19793
+ const sso = req2.session?.get?.("_tina4_sso");
19794
+ const identity = sso?.identity;
19795
+ if (identity?.issuer && identity?.subject) {
19796
+ req2.user = identity;
19797
+ return false;
19798
+ }
19524
19799
  const sessionToken = req2.session?.get?.("token");
19525
19800
  if (sessionToken && validToken(sessionToken)) {
19526
19801
  resolvedToken = sessionToken;
@@ -27852,17 +28127,67 @@ var init_mongoBackend = __esm({
27852
28127
  process.stdout.write("__OK__");
27853
28128
  }
27854
28129
  else if (operation === "retry") {
27855
- // Explicit manual re-queue (always re-enqueues). data = JSON
27856
- // { id, delaySeconds }.
28130
+ // Explicit manual re-queue. Serves BOTH Queue.retry(id) (revive
28131
+ // a dead-letter job) AND job.retry() (manual re-queue of a live
28132
+ // reserved/pending job) so the Mongo backend matches
28133
+ // LiteBackend's dual behaviour.
28134
+ //
28135
+ // 1) DL revival (Queue.retry(id) after fail exhausted retries).
28136
+ // Pre-3.13.105 this branch was BROKEN: the search filter was
28137
+ // { queue: queueName, id, status: "failed" } -- three separate
28138
+ // reasons it could never match. dead_letter() inserts under
28139
+ // queueName + ".dead_letter" (not queueName), carries
28140
+ // status "dead" (not "failed"), and the original under
28141
+ // queueName was already acked to "completed" by the time the
28142
+ // DL was written. Now we look up in the DL namespace by id,
28143
+ // delete the DL doc first (so an interrupted retry never
28144
+ // leaves both a DL and a fresh pending doc), and upsert the
28145
+ // original back to pending -- re-hydrating if the original
28146
+ // was purged (housekeeping) so a retry always works.
28147
+ // 2) Live-doc manual re-queue (job.retry() on a job the caller
28148
+ // just popped and wants back in pending). The live-doc path
28149
+ // is preserved from before 3.13.105.
28150
+ //
28151
+ // Returns __OK__ when either path acted; __NOT_FOUND__ when
28152
+ // neither the DL nor the live doc existed, so Queue.retry(id)
28153
+ // can now report the pre-3.13.105 blanket-true as false for
28154
+ // unknown ids. data = JSON { id, delaySeconds }.
27857
28155
  const info = JSON.parse(data);
28156
+ const dlTopic = queueName + ".dead_letter";
28157
+ const now = new Date().toISOString();
27858
28158
  const avail = info.delaySeconds > 0
27859
28159
  ? new Date(Date.now() + info.delaySeconds * 1000).toISOString()
27860
- : new Date().toISOString();
27861
- await col.updateOne(
27862
- { queue: queueName, id: info.id },
27863
- { $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
27864
- );
27865
- process.stdout.write("__OK__");
28160
+ : now;
28161
+ const dlDoc = await col.findOne({ queue: dlTopic, id: info.id });
28162
+ if (dlDoc !== null) {
28163
+ await col.deleteOne({ _id: dlDoc._id });
28164
+ const payload = dlDoc.payload ?? {};
28165
+ const priority = dlDoc.priority ?? 0;
28166
+ await col.updateOne(
28167
+ { queue: queueName, id: info.id },
28168
+ {
28169
+ $set: {
28170
+ status: "pending",
28171
+ availableAt: avail,
28172
+ reservedAt: null,
28173
+ error: null,
28174
+ payload,
28175
+ priority,
28176
+ id: info.id,
28177
+ createdAt: dlDoc.createdAt ?? now,
28178
+ },
28179
+ $inc: { attempts: 1 },
28180
+ },
28181
+ { upsert: true },
28182
+ );
28183
+ process.stdout.write("__OK__");
28184
+ } else {
28185
+ const result = await col.updateOne(
28186
+ { queue: queueName, id: info.id },
28187
+ { $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
28188
+ );
28189
+ process.stdout.write(result.matchedCount > 0 ? "__OK__" : "__NOT_FOUND__");
28190
+ }
27866
28191
  }
27867
28192
  else if (operation === "deadLetters") {
27868
28193
  const docs = await col.find({ queue: queueName + ".dead_letter" }).toArray();
@@ -27907,10 +28232,20 @@ var init_mongoBackend = __esm({
27907
28232
  process.stdout.write(String(revived));
27908
28233
  }
27909
28234
  else if (operation === "purge") {
27910
- // Delete docs by status (default: all for the topic). data = JSON { status }.
28235
+ // Delete docs by status (default: every doc for the topic).
28236
+ // Pre-3.13.105 this filtered by { queue: queueName, status } for
28237
+ // EVERY status -- correct for pending/reserved/completed, wrong
28238
+ // for the dead-letter states (dead/failed/dead_letter) which
28239
+ // live under queueName + ".dead_letter" and carry status "dead".
28240
+ // A purge("dead") therefore deleted nothing and returned 0.
28241
+ // data = JSON { status }.
27911
28242
  const info = data ? JSON.parse(data) : {};
27912
- const filter = { queue: queueName };
27913
- if (info.status) filter.status = info.status;
28243
+ const isDead = info.status && ["dead", "failed", "dead_letter"].includes(info.status);
28244
+ const filter = isDead
28245
+ ? { queue: queueName + ".dead_letter" }
28246
+ : (info.status
28247
+ ? { queue: queueName, status: info.status }
28248
+ : { queue: queueName });
27914
28249
  const res = await col.deleteMany(filter);
27915
28250
  process.stdout.write(String(res.deletedCount || 0));
27916
28251
  }
@@ -28007,9 +28342,15 @@ var init_mongoBackend = __esm({
28007
28342
  fail(queue, id, error, maxRetries, retryBackoff = 0) {
28008
28343
  this.execSync("fail", queue, JSON.stringify({ id, error, maxRetries, retryBackoff }));
28009
28344
  }
28010
- /** Explicit manual re-queue (always re-enqueues regardless of the retry limit). */
28345
+ /**
28346
+ * Revive a specific dead-letter job by id. Returns true if the DL was found
28347
+ * and revived, false otherwise (parity with LiteBackend.retry(queue, id)
28348
+ * and Python's mongo_backend.retry_job()). Pre-3.13.105 this returned void
28349
+ * and Queue.retry(id) reported success for every call, even for unknown ids.
28350
+ */
28011
28351
  retry(queue, id, delaySeconds = 0) {
28012
- this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
28352
+ const out = this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
28353
+ return out.includes("__OK__");
28013
28354
  }
28014
28355
  /** Jobs that exceeded max retries (the `<queue>.dead_letter` collection topic). */
28015
28356
  deadLetters(queue, maxRetries) {
@@ -28678,10 +29019,20 @@ var init_liteBackend = __esm({
28678
29019
  * Explicit re-queue requested by the caller (job.retry()).
28679
29020
  *
28680
29021
  * Always re-enqueues regardless of the retry limit — manual override,
28681
- * distinct from the automatic failJob() path.
29022
+ * distinct from the automatic failJob() path. Cleans up BOTH the
29023
+ * reservation record AND any dead-letter file for this id, so a caller
29024
+ * that iterates deadLetters() and calls .retry() on each doesn't leave
29025
+ * the failed/ directory carrying duplicates (PY-12-05, 3.13.105).
29026
+ * Aligns with retry(queue, jobId) which had always unlinked the
29027
+ * dead-letter file -- two spellings of the same intent that previously
29028
+ * diverged.
28682
29029
  */
28683
29030
  retryJob(queue, job, delaySeconds) {
28684
29031
  this.clearReservation(queue, job.id);
29032
+ try {
29033
+ unlinkSync7(join23(this.ensureFailedDir(queue), `${job.id}.queue-data`));
29034
+ } catch {
29035
+ }
28685
29036
  job.attempts = (job.attempts || 0) + 1;
28686
29037
  job.error = void 0;
28687
29038
  this.requeue(queue, job, delaySeconds ?? 0, void 0);
@@ -28880,7 +29231,19 @@ var init_queue = __esm({
28880
29231
  }
28881
29232
  }
28882
29233
  /**
28883
- * Count jobs filtered by status. Defaults to "pending".
29234
+ * Count jobs by status. Defaults to "pending".
29235
+ *
29236
+ * ``"pending"`` counts jobs waiting to be popped -- INCLUDES retryable-
29237
+ * but-attempted ones, because they live in the pending queue under the
29238
+ * auto-retry lifecycle (see failed()).
29239
+ * ``"reserved"`` counts jobs a consumer has popped but not yet
29240
+ * completed/failed (in-flight against the visibility timeout).
29241
+ * ``"completed"`` counts jobs the consumer has finished successfully.
29242
+ * ``"failed"``, ``"dead"``, ``"dead_letter"`` are ALIASES that all count
29243
+ * the dead-letter store -- jobs whose attempts >= maxRetries and that
29244
+ * have given up. Use deadLetters() to list them. Retryable-but-attempted
29245
+ * jobs are NOT counted by size("failed"); use failed() to list them or
29246
+ * size("pending") to include them in a total.
28884
29247
  */
28885
29248
  size(status2 = "pending") {
28886
29249
  const q = this.topic;
@@ -28930,13 +29293,17 @@ var init_queue = __esm({
28930
29293
  /**
28931
29294
  * Get jobs that failed at least once but are still being retried
28932
29295
  * (0 < attempts < maxRetries). These live in the pending queue under the
28933
- * auto-retry lifecycle; dead-lettered jobs are returned by deadLetters().
29296
+ * auto-retry lifecycle (fail() re-queues them with an incremented attempts
29297
+ * count and a retryBackoff delay) so pop() picks them up again. They are
29298
+ * NOT counted by size("failed") -- that alias counts the dead-letter store,
29299
+ * matching deadLetters(). To include retryable-failed jobs in a total, use
29300
+ * size("pending"). Terminal failures are returned by deadLetters().
28934
29301
  */
28935
29302
  failed() {
28936
- if (this.externalBackend?.failed) {
28937
- return this.externalBackend.failed(this.topic, this._maxRetries);
28938
- }
28939
- return this.liteBackend.failed(this.topic, this._maxRetries);
29303
+ const raw = this.externalBackend?.failed ? this.externalBackend.failed(this.topic, this._maxRetries) : this.liteBackend.failed(this.topic, this._maxRetries);
29304
+ return raw.map(
29305
+ (data) => createJob({ ...data, topic: data.topic ?? this.topic }, this)
29306
+ );
28940
29307
  }
28941
29308
  /**
28942
29309
  * Retry all dead letter jobs for this queue's topic.
@@ -28948,8 +29315,8 @@ var init_queue = __esm({
28948
29315
  retry(jobId, delaySeconds) {
28949
29316
  if (jobId) {
28950
29317
  if (this.externalBackend?.retry) {
28951
- this.externalBackend.retry(this.topic, jobId, delaySeconds);
28952
- return true;
29318
+ const result = this.externalBackend.retry(this.topic, jobId, delaySeconds);
29319
+ return result === void 0 ? true : Boolean(result);
28953
29320
  }
28954
29321
  return this.liteBackend.retry(this.topic, jobId, delaySeconds);
28955
29322
  }
@@ -28958,8 +29325,8 @@ var init_queue = __esm({
28958
29325
  let retried = false;
28959
29326
  for (const job of deadJobs) {
28960
29327
  if (this.externalBackend?.retry) {
28961
- this.externalBackend.retry(this.topic, job.id, delaySeconds);
28962
- retried = true;
29328
+ const result = this.externalBackend.retry(this.topic, job.id, delaySeconds);
29329
+ if (result === void 0 || Boolean(result)) retried = true;
28963
29330
  } else if (this.liteBackend.retry(this.topic, job.id, delaySeconds)) {
28964
29331
  retried = true;
28965
29332
  }
@@ -28967,13 +29334,28 @@ var init_queue = __esm({
28967
29334
  return retried;
28968
29335
  }
28969
29336
  /**
28970
- * Get dead letter jobs failed jobs that exceeded max retries.
29337
+ * Get jobs that exceeded max_retries -- terminal failures.
29338
+ *
29339
+ * Same set counted by size("failed") / size("dead") / size("dead_letter")
29340
+ * (three aliases for the dead-letter store). To LIST retryable-but-
29341
+ * attempted jobs (attempts > 0 AND attempts < maxRetries) that are still
29342
+ * being auto-retried, use failed() -- those live in the pending queue and
29343
+ * are NOT dead letters.
29344
+ *
29345
+ * Returns Job objects with the failure reason on ``.error`` (not raw dicts)
29346
+ * so callers can iterate uniformly with the rest of the queue API and, in
29347
+ * particular, call ``.retry()`` on each to manually revive it:
29348
+ *
29349
+ * for (const job of queue.deadLetters()) {
29350
+ * Log.warn(`revived ${job.id}: ${job.error}`);
29351
+ * job.retry();
29352
+ * }
28971
29353
  */
28972
29354
  deadLetters(maxRetries) {
28973
- if (this.externalBackend?.deadLetters) {
28974
- return this.externalBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
28975
- }
28976
- return this.liteBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
29355
+ const raw = this.externalBackend?.deadLetters ? this.externalBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries) : this.liteBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
29356
+ return raw.map(
29357
+ (data) => createJob({ ...data, topic: data.topic ?? this.topic }, this)
29358
+ );
28977
29359
  }
28978
29360
  /**
28979
29361
  * Delete messages by status (e.g. "completed", "failed", "dead").
@@ -34092,6 +34474,14 @@ function resolveSecuritySchemes() {
34092
34474
  const apiKeyIn = ["header", "query", "cookie"].includes(rawIn) ? rawIn : "header";
34093
34475
  schemes.apiKeyAuth = { type: "apiKey", name: apiKeyName, in: apiKeyIn };
34094
34476
  }
34477
+ const ssoIssuer = (process.env.TINA4_SSO_ISSUER ?? "").replace(/\/$/, "");
34478
+ if (ssoIssuer) {
34479
+ schemes.oidc = {
34480
+ type: "openIdConnect",
34481
+ openIdConnectUrl: `${ssoIssuer}/.well-known/openid-configuration`
34482
+ };
34483
+ schemes.ssoSession = { type: "apiKey", in: "cookie", name: "tina4_session" };
34484
+ }
34095
34485
  for (const [name, def] of Object.entries(registeredSchemes)) {
34096
34486
  schemes[name] = def;
34097
34487
  }
@@ -34273,7 +34663,9 @@ function generate(routes, models = []) {
34273
34663
  if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
34274
34664
  }
34275
34665
  } else if (routeRequiresAuth(route, method)) {
34276
- operation.security = sanitizeSecurity([{ [defaultScheme]: [] }], schemes);
34666
+ const requirements = [{ [defaultScheme]: [] }];
34667
+ if (defaultScheme === "bearerAuth" && schemes.ssoSession) requirements.push({ ssoSession: [] });
34668
+ operation.security = sanitizeSecurity(requirements, schemes);
34277
34669
  const responses = operation.responses;
34278
34670
  if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
34279
34671
  }
@@ -34587,6 +34979,298 @@ var init_src2 = __esm({
34587
34979
  }
34588
34980
  });
34589
34981
 
34982
+ // ../core/src/sso.ts
34983
+ var sso_exports = {};
34984
+ __export(sso_exports, {
34985
+ SSO: () => Sso,
34986
+ Sso: () => Sso,
34987
+ SsoError: () => SsoError
34988
+ });
34989
+ import { createHash as createHash9, randomBytes as randomBytes7, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
34990
+ var SsoError, Sso;
34991
+ var init_sso = __esm({
34992
+ "../core/src/sso.ts"() {
34993
+ "use strict";
34994
+ SsoError = class extends Error {
34995
+ };
34996
+ Sso = class _Sso {
34997
+ static PENDING_KEY = "_tina4_sso_pending";
34998
+ static SESSION_KEY = "_tina4_sso";
34999
+ issuer;
35000
+ clientId;
35001
+ clientSecret;
35002
+ redirectUri;
35003
+ scopes;
35004
+ verify;
35005
+ postLogoutRedirectUri;
35006
+ claimMap;
35007
+ timeout;
35008
+ metadata = {};
35009
+ static mountedRouters = /* @__PURE__ */ new WeakSet();
35010
+ constructor(options = {}) {
35011
+ this.issuer = (options.issuer ?? process.env.TINA4_SSO_ISSUER ?? "").replace(/\/$/, "");
35012
+ this.clientId = options.clientId ?? process.env.TINA4_SSO_CLIENT_ID ?? "";
35013
+ this.clientSecret = options.clientSecret ?? process.env.TINA4_SSO_CLIENT_SECRET;
35014
+ this.redirectUri = options.redirectUri ?? process.env.TINA4_SSO_REDIRECT_URI ?? "";
35015
+ this.scopes = options.scopes ?? this.jsonEnv("TINA4_SSO_SCOPES", ["openid", "profile", "email"]);
35016
+ this.verify = options.verify ?? process.env.TINA4_SSO_VERIFY ?? "introspection";
35017
+ this.postLogoutRedirectUri = options.postLogoutRedirectUri ?? process.env.TINA4_SSO_POST_LOGOUT_REDIRECT_URI;
35018
+ this.claimMap = options.claimMap ?? this.jsonEnv("TINA4_SSO_CLAIM_MAP", {});
35019
+ this.timeout = options.timeout ?? 1e4;
35020
+ this.validateConfig();
35021
+ }
35022
+ static async fromIssuer(options = {}) {
35023
+ const value = new _Sso(options);
35024
+ await value.discover();
35025
+ return value;
35026
+ }
35027
+ static configured() {
35028
+ return ["TINA4_SSO_ISSUER", "TINA4_SSO_CLIENT_ID", "TINA4_SSO_REDIRECT_URI"].every((key) => Boolean(process.env[key]));
35029
+ }
35030
+ jsonEnv(name, fallback) {
35031
+ const raw = process.env[name];
35032
+ if (!raw) return fallback;
35033
+ try {
35034
+ return JSON.parse(raw);
35035
+ } catch {
35036
+ throw new SsoError(`${name} must be valid JSON`);
35037
+ }
35038
+ }
35039
+ static secureUrl(value, name) {
35040
+ let url;
35041
+ try {
35042
+ url = new URL(value);
35043
+ } catch {
35044
+ throw new SsoError(`${name} must be an absolute URL`);
35045
+ }
35046
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
35047
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
35048
+ throw new SsoError(`${name} must use HTTPS except on loopback`);
35049
+ }
35050
+ }
35051
+ validateConfig() {
35052
+ if (!this.issuer || !this.clientId || !this.redirectUri) {
35053
+ throw new SsoError("TINA4_SSO_ISSUER, TINA4_SSO_CLIENT_ID and TINA4_SSO_REDIRECT_URI are required");
35054
+ }
35055
+ _Sso.secureUrl(this.issuer, "issuer");
35056
+ _Sso.secureUrl(this.redirectUri, "redirect URI");
35057
+ if (!["introspection", "jwks"].includes(this.verify)) throw new SsoError("TINA4_SSO_VERIFY must be introspection or jwks");
35058
+ if (this.verify === "jwks") throw new SsoError("jwks verification requires an installed cryptography capability");
35059
+ if (this.verify === "introspection" && !this.clientSecret) throw new SsoError("introspection verification requires TINA4_SSO_CLIENT_SECRET");
35060
+ if (!Array.isArray(this.scopes) || !this.scopes.includes("openid")) throw new SsoError("TINA4_SSO_SCOPES must be a list containing openid");
35061
+ }
35062
+ async requestJson(url, form, bearer, basic = false) {
35063
+ const headers = { Accept: "application/json" };
35064
+ let body;
35065
+ if (form) {
35066
+ const parameters = new URLSearchParams();
35067
+ for (const [key, value] of Object.entries(form)) parameters.set(key, String(value));
35068
+ body = parameters.toString();
35069
+ headers["Content-Type"] = "application/x-www-form-urlencoded";
35070
+ }
35071
+ if (bearer) headers.Authorization = `Bearer ${bearer}`;
35072
+ if (basic) headers.Authorization = `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString("base64")}`;
35073
+ const controller = new AbortController();
35074
+ const timer = setTimeout(() => controller.abort(), this.timeout);
35075
+ try {
35076
+ const response = await fetch(url, { method: form ? "POST" : "GET", headers, body, signal: controller.signal });
35077
+ if (!response.ok) throw new SsoError("OIDC provider request failed");
35078
+ const result = await response.json();
35079
+ if (!result || typeof result !== "object" || Array.isArray(result)) throw new SsoError("OIDC provider returned a non-object response");
35080
+ return result;
35081
+ } catch (error) {
35082
+ if (error instanceof SsoError) throw error;
35083
+ throw new SsoError("OIDC provider request failed");
35084
+ } finally {
35085
+ clearTimeout(timer);
35086
+ }
35087
+ }
35088
+ async discover(force = false) {
35089
+ if (Object.keys(this.metadata).length && !force) return { ...this.metadata };
35090
+ const result = await this.requestJson(`${this.issuer}/.well-known/openid-configuration`);
35091
+ if (result.issuer !== this.issuer) throw new SsoError("OIDC discovery issuer does not exactly match configuration");
35092
+ const required = ["authorization_endpoint", "token_endpoint"];
35093
+ if (this.verify === "introspection") required.push("introspection_endpoint");
35094
+ for (const key of required) {
35095
+ if (!result[key]) throw new SsoError(`OIDC discovery is missing ${key}`);
35096
+ _Sso.secureUrl(result[key], key);
35097
+ }
35098
+ this.metadata = result;
35099
+ return { ...result };
35100
+ }
35101
+ static safeReturn(value) {
35102
+ if (!value || !value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return "/";
35103
+ return [...value].some((char) => char.charCodeAt(0) < 32) ? "/" : value;
35104
+ }
35105
+ session(value) {
35106
+ return value?.session ?? value;
35107
+ }
35108
+ async login(requestOrSession, returnTo = "/") {
35109
+ const session = this.session(requestOrSession);
35110
+ if (!session) throw new SsoError("SSO login requires a Tina4 Session");
35111
+ const state = randomBytes7(32).toString("base64url");
35112
+ const nonce = randomBytes7(32).toString("base64url");
35113
+ const verifier = randomBytes7(64).toString("base64url");
35114
+ const challenge = createHash9("sha256").update(verifier).digest("base64url");
35115
+ session.set(_Sso.PENDING_KEY, { state, nonce, verifier, return_to: _Sso.safeReturn(returnTo), created_at: Math.floor(Date.now() / 1e3) });
35116
+ const metadata = await this.discover();
35117
+ const query = new URLSearchParams({
35118
+ client_id: this.clientId,
35119
+ redirect_uri: this.redirectUri,
35120
+ response_type: "code",
35121
+ scope: this.scopes.join(" "),
35122
+ state,
35123
+ nonce,
35124
+ code_challenge: challenge,
35125
+ code_challenge_method: "S256"
35126
+ });
35127
+ return `${metadata.authorization_endpoint}?${query}`;
35128
+ }
35129
+ static equal(left, right) {
35130
+ const a = Buffer.from(String(left ?? ""));
35131
+ const b = Buffer.from(String(right ?? ""));
35132
+ return a.length === b.length && timingSafeEqual3(a, b);
35133
+ }
35134
+ static jwtPayload(token) {
35135
+ try {
35136
+ return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString());
35137
+ } catch {
35138
+ throw new SsoError("provider returned an invalid ID token");
35139
+ }
35140
+ }
35141
+ async introspect(accessToken) {
35142
+ const metadata = await this.discover();
35143
+ const result = await this.requestJson(metadata.introspection_endpoint, { token: accessToken, token_type_hint: "access_token" }, void 0, true);
35144
+ if (result.active !== true || result.iss !== this.issuer) throw new SsoError("OIDC access token is inactive or has the wrong issuer");
35145
+ const audience = result.aud ?? result.client_id;
35146
+ const valid = (Array.isArray(audience) ? audience.includes(this.clientId) : audience === this.clientId) || result.client_id === this.clientId;
35147
+ if (!valid) throw new SsoError("OIDC token audience mismatch");
35148
+ return result;
35149
+ }
35150
+ claim(claims, configured, fallback) {
35151
+ let value = claims;
35152
+ for (const part of (configured ?? fallback).split(".")) value = value && typeof value === "object" ? value[part] : void 0;
35153
+ return value;
35154
+ }
35155
+ normalize(claims) {
35156
+ const subject = this.claim(claims, this.claimMap.subject, "sub");
35157
+ const issuer = this.claim(claims, this.claimMap.issuer, "iss") ?? this.issuer;
35158
+ if (!subject || issuer !== this.issuer) throw new SsoError("OIDC identity is missing a valid issuer or subject");
35159
+ const roles = [...this.claim(claims, this.claimMap.roles, "realm_access.roles") ?? [], ...claims.resource_access?.[this.clientId]?.roles ?? []];
35160
+ const groups = this.claim(claims, this.claimMap.groups, "groups") ?? [];
35161
+ return {
35162
+ issuer,
35163
+ subject,
35164
+ username: this.claim(claims, this.claimMap.username, "preferred_username") ?? null,
35165
+ email: this.claim(claims, this.claimMap.email, "email") ?? null,
35166
+ name: this.claim(claims, this.claimMap.name, "name") ?? null,
35167
+ roles: [...new Set(roles.map(String))].sort(),
35168
+ groups: [...new Set(groups.map(String))].sort()
35169
+ };
35170
+ }
35171
+ async callback(requestOrSession, query) {
35172
+ const session = this.session(requestOrSession);
35173
+ const values = query ?? requestOrSession?.query ?? {};
35174
+ const pending = session?.get(_Sso.PENDING_KEY);
35175
+ session?.delete(_Sso.PENDING_KEY);
35176
+ if (!pending || !values.code || !_Sso.equal(values.state, pending.state)) throw new SsoError("OIDC callback state is invalid or already consumed");
35177
+ if (Math.floor(Date.now() / 1e3) - Number(pending.created_at ?? 0) > 600) throw new SsoError("OIDC callback state has expired");
35178
+ const metadata = await this.discover();
35179
+ const tokens = await this.requestJson(metadata.token_endpoint, {
35180
+ grant_type: "authorization_code",
35181
+ code: values.code,
35182
+ redirect_uri: this.redirectUri,
35183
+ client_id: this.clientId,
35184
+ code_verifier: pending.verifier
35185
+ }, void 0, Boolean(this.clientSecret));
35186
+ if (!tokens.access_token || !tokens.id_token) throw new SsoError("OIDC token response is incomplete");
35187
+ if (this.verify === "jwks") throw new SsoError("JWKS verification requires an installed cryptography capability");
35188
+ const claims = await this.introspect(tokens.access_token);
35189
+ if (!_Sso.equal(_Sso.jwtPayload(tokens.id_token).nonce, pending.nonce)) throw new SsoError("OIDC ID token nonce mismatch");
35190
+ if (metadata.userinfo_endpoint) Object.assign(claims, await this.requestJson(metadata.userinfo_endpoint, void 0, tokens.access_token));
35191
+ const identity = this.normalize(claims);
35192
+ session.regenerate();
35193
+ session.set(_Sso.SESSION_KEY, {
35194
+ version: 1,
35195
+ identity,
35196
+ access_token: tokens.access_token,
35197
+ refresh_token: tokens.refresh_token,
35198
+ id_token: tokens.id_token,
35199
+ expires_at: Math.floor(Date.now() / 1e3) + Number(tokens.expires_in ?? 0)
35200
+ });
35201
+ return { identity, return_to: _Sso.safeReturn(pending.return_to) };
35202
+ }
35203
+ identity(requestOrSession) {
35204
+ const stored = this.session(requestOrSession)?.get(_Sso.SESSION_KEY);
35205
+ const identity = stored?.identity ?? null;
35206
+ if (identity && requestOrSession?.session) requestOrSession.user = identity;
35207
+ return identity;
35208
+ }
35209
+ async refresh(requestOrSession) {
35210
+ const session = this.session(requestOrSession);
35211
+ const stored = session?.get(_Sso.SESSION_KEY);
35212
+ if (!stored?.refresh_token) {
35213
+ session?.delete(_Sso.SESSION_KEY);
35214
+ throw new SsoError("OIDC session cannot be refreshed");
35215
+ }
35216
+ try {
35217
+ const metadata = await this.discover();
35218
+ const tokens = await this.requestJson(metadata.token_endpoint, {
35219
+ grant_type: "refresh_token",
35220
+ refresh_token: stored.refresh_token,
35221
+ client_id: this.clientId
35222
+ }, void 0, Boolean(this.clientSecret));
35223
+ const claims = await this.introspect(tokens.access_token);
35224
+ if (metadata.userinfo_endpoint) Object.assign(claims, await this.requestJson(metadata.userinfo_endpoint, void 0, tokens.access_token));
35225
+ const identity = this.normalize(claims);
35226
+ session.set(_Sso.SESSION_KEY, {
35227
+ ...stored,
35228
+ identity,
35229
+ access_token: tokens.access_token,
35230
+ refresh_token: tokens.refresh_token ?? stored.refresh_token,
35231
+ id_token: tokens.id_token ?? stored.id_token,
35232
+ expires_at: Math.floor(Date.now() / 1e3) + Number(tokens.expires_in ?? 0)
35233
+ });
35234
+ return identity;
35235
+ } catch (error) {
35236
+ session?.delete(_Sso.SESSION_KEY);
35237
+ throw error;
35238
+ }
35239
+ }
35240
+ async logout(requestOrSession, returnTo = "/") {
35241
+ const session = this.session(requestOrSession);
35242
+ const stored = session?.get(_Sso.SESSION_KEY);
35243
+ session?.destroy();
35244
+ const endpoint = (await this.discover()).end_session_endpoint;
35245
+ const target = this.postLogoutRedirectUri ?? _Sso.safeReturn(returnTo);
35246
+ if (!endpoint) return target;
35247
+ const params = new URLSearchParams({ post_logout_redirect_uri: target, client_id: this.clientId });
35248
+ if (stored?.id_token) params.set("id_token_hint", stored.id_token);
35249
+ return `${endpoint}?${params}`;
35250
+ }
35251
+ static async mountConfigured(router) {
35252
+ if (_Sso.mountedRouters.has(router) || !_Sso.configured()) return false;
35253
+ const owned = /* @__PURE__ */ new Set(["GET /auth/login", "GET /auth/callback", "POST /auth/logout"]);
35254
+ const collisions = router.getRoutes().map((route) => `${route.method} ${route.pattern}`).filter((route) => owned.has(route));
35255
+ if (collisions.length) throw new SsoError(`SSO route collision: ${collisions.join(", ")}`);
35256
+ const sso = await _Sso.fromIssuer();
35257
+ router.get("/auth/login", async (req2, res) => res.redirect(await sso.login(req2, req2.query?.return_to ?? "/")));
35258
+ router.get("/auth/callback", async (req2, res) => {
35259
+ try {
35260
+ return res.redirect((await sso.callback(req2)).return_to);
35261
+ } catch (error) {
35262
+ const message = error instanceof SsoError ? error.message : "OIDC callback failed";
35263
+ return res.error("SSO_CALLBACK_FAILED", message, 400);
35264
+ }
35265
+ });
35266
+ router.post("/auth/logout", async (req2, res) => res.redirect(await sso.logout(req2, req2.query?.return_to ?? "/")));
35267
+ _Sso.mountedRouters.add(router);
35268
+ return true;
35269
+ }
35270
+ };
35271
+ }
35272
+ });
35273
+
34590
35274
  // ../core/src/docsAutoDiscovery.ts
34591
35275
  var docsAutoDiscovery_exports = {};
34592
35276
  __export(docsAutoDiscovery_exports, {
@@ -34656,7 +35340,7 @@ var init_docsAutoDiscovery = __esm({
34656
35340
 
34657
35341
  // ../core/src/server.ts
34658
35342
  import { createServer as createServer2 } from "node:http";
34659
- import { randomBytes as randomBytes7 } from "node:crypto";
35343
+ import { randomBytes as randomBytes8 } from "node:crypto";
34660
35344
  import { resolve as resolve19, dirname as dirname14, join as join30, relative as relative8 } from "node:path";
34661
35345
  import { existsSync as existsSync25, readdirSync as readdirSync17, readFileSync as readFileSync23, statSync as statSync17 } from "node:fs";
34662
35346
  import { isatty } from "node:tty";
@@ -35296,7 +35980,7 @@ async function renderDispatchError(err, req2, res, templatesDir) {
35296
35980
  }
35297
35981
  }
35298
35982
  }
35299
- const requestId = Log.getRequestId() ?? randomBytes7(4).toString("hex");
35983
+ const requestId = Log.getRequestId() ?? randomBytes8(4).toString("hex");
35300
35984
  if (wantsJson(req2)) {
35301
35985
  const body = negotiatedErrorBody(500, "Internal Server Error", requestId);
35302
35986
  res.raw.writeHead(500, { "Content-Type": "application/json" });
@@ -35367,7 +36051,7 @@ function serveStaticAsset(ctx) {
35367
36051
  return false;
35368
36052
  }
35369
36053
  async function serveNotFound(ctx) {
35370
- const requestId = Log.getRequestId() ?? randomBytes7(4).toString("hex");
36054
+ const requestId = Log.getRequestId() ?? randomBytes8(4).toString("hex");
35371
36055
  if (wantsJson(ctx.req)) {
35372
36056
  const body = negotiatedErrorBody(404, "Not Found", requestId);
35373
36057
  ctx.res.raw.writeHead(404, httpReason(404), { "Content-Type": "application/json" });
@@ -35487,7 +36171,7 @@ async function dispatchInner(ctx, rawReq, rawRes, requestId) {
35487
36171
  }
35488
36172
  }
35489
36173
  async function runDispatch(ctx, rawReq, rawRes) {
35490
- const requestId = Log.sanitizeRequestId(rawReq.headers["x-request-id"]) ?? randomBytes7(4).toString("hex");
36174
+ const requestId = Log.sanitizeRequestId(rawReq.headers["x-request-id"]) ?? randomBytes8(4).toString("hex");
35491
36175
  if (!rawRes.headersSent) rawRes.setHeader("x-request-id", requestId);
35492
36176
  return Log.runWithRequestId(requestId, () => dispatchInner(ctx, rawReq, rawRes, requestId));
35493
36177
  }
@@ -35621,6 +36305,8 @@ ${reset2}
35621
36305
  console.log(`
35622
36306
  No routes directory found at ${routesDir}`);
35623
36307
  }
36308
+ const { Sso: Sso2 } = await Promise.resolve().then(() => (init_sso(), sso_exports));
36309
+ await Sso2.mountConfigured(router);
35624
36310
  if (attachCsrfFromEnv()) {
35625
36311
  console.log(`
35626
36312
  \x1B[36mCSRF\x1B[0m protection enabled (TINA4_CSRF)`);
@@ -36145,7 +36831,7 @@ var init_mqttMessage = __esm({
36145
36831
  // ../core/src/mqtt.ts
36146
36832
  import net2 from "node:net";
36147
36833
  import tls from "node:tls";
36148
- import { randomBytes as randomBytes8 } from "node:crypto";
36834
+ import { randomBytes as randomBytes9 } from "node:crypto";
36149
36835
  import { existsSync as existsSync26, readFileSync as readFileSync24 } from "node:fs";
36150
36836
  var MqttError, MqttTimeoutError, CONNECT, CONNACK, PUBLISH, PUBACK, SUBSCRIBE, SUBACK, PINGREQ, PINGRESP, DISCONNECT, PROTOCOL_LEVEL, DEFAULT_PORT, DEFAULT_TLS_PORT, DEFAULT_URL, DEFAULT_KEEPALIVE, SUBSCRIPTION_REFUSED, MAX_REMAINING_LENGTH, QOS2_REFUSED_MESSAGE, CONNACK_RETURN_CODES, Mqtt;
36151
36837
  var init_mqtt = __esm({
@@ -36233,7 +36919,7 @@ var init_mqtt = __esm({
36233
36919
  this.caFile = options.caFile ?? (Env.str("TINA4_MQTT_CA_FILE") || null);
36234
36920
  this.tlsVerify = options.tlsVerify ?? Env.bool("TINA4_MQTT_TLS_VERIFY", true);
36235
36921
  let cid = options.clientId ?? (Env.str("TINA4_MQTT_CLIENT_ID") || null);
36236
- if (cid === null || cid === "") cid = "tina4-" + randomBytes8(8).toString("hex");
36922
+ if (cid === null || cid === "") cid = "tina4-" + randomBytes9(8).toString("hex");
36237
36923
  this.clientId = cid;
36238
36924
  this.keepalive = options.keepalive ?? Env.int("TINA4_MQTT_KEEPALIVE", DEFAULT_KEEPALIVE);
36239
36925
  this.cleanSession = options.cleanSession ?? true;
@@ -37163,7 +37849,7 @@ var init_service = __esm({
37163
37849
  import http from "node:http";
37164
37850
  import https from "node:https";
37165
37851
  import { URL as URL2 } from "node:url";
37166
- import { randomBytes as randomBytes9 } from "node:crypto";
37852
+ import { randomBytes as randomBytes10 } from "node:crypto";
37167
37853
  import { promises as fsp, createWriteStream } from "node:fs";
37168
37854
  import { basename as basename6 } from "node:path";
37169
37855
  import { pipeline } from "node:stream/promises";
@@ -37461,7 +38147,7 @@ var init_api = __esm({
37461
38147
  return { http_code: null, body: null, headers: {}, error: "upload requires filePath or fileBytes" };
37462
38148
  }
37463
38149
  const partContentType = guessContentType(uploadName);
37464
- const boundary = "----Tina4Boundary" + randomBytes9(16).toString("hex");
38150
+ const boundary = "----Tina4Boundary" + randomBytes10(16).toString("hex");
37465
38151
  const bodyBuffer = buildMultipartBody(boundary, fieldName, uploadName, content, partContentType, extraFields);
37466
38152
  const contentType = `multipart/form-data; boundary=${boundary}`;
37467
38153
  return this.execute("POST", this.buildUrl(path8), bodyBuffer, contentType, headers);
@@ -42311,10 +42997,13 @@ __export(src_exports3, {
42311
42997
  RouteGroup: () => RouteGroup,
42312
42998
  RouteRef: () => RouteRef,
42313
42999
  Router: () => Router,
43000
+ SSO: () => Sso,
42314
43001
  SafeString: () => SafeString2,
42315
43002
  SecurityHeadersMiddleware: () => SecurityHeadersMiddleware,
42316
43003
  ServiceRunner: () => ServiceRunner,
42317
43004
  Session: () => Session,
43005
+ Sso: () => Sso,
43006
+ SsoError: () => SsoError,
42318
43007
  TAKEOVER_KILLED: () => TAKEOVER_KILLED,
42319
43008
  TAKEOVER_NOTHING: () => TAKEOVER_NOTHING,
42320
43009
  TAKEOVER_REFUSALS: () => TAKEOVER_REFUSALS,
@@ -42551,6 +43240,7 @@ var init_src3 = __esm({
42551
43240
  init_htmlElement();
42552
43241
  init_errorOverlay();
42553
43242
  init_ai();
43243
+ init_sso();
42554
43244
  init_aiClient();
42555
43245
  init_liteBackend();
42556
43246
  init_rabbitmqBackend();