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
@@ -6014,13 +6014,172 @@ var init_databaseUrl = __esm({
6014
6014
  }
6015
6015
  });
6016
6016
 
6017
+ // ../orm/src/point.ts
6018
+ function formatCoordinate(value) {
6019
+ return Object.is(value, -0) ? "0" : Number(value.toPrecision(15)).toString();
6020
+ }
6021
+ var DEFAULT_SRID, SpatialNotSupportedError, Point;
6022
+ var init_point = __esm({
6023
+ "../orm/src/point.ts"() {
6024
+ "use strict";
6025
+ DEFAULT_SRID = 4326;
6026
+ SpatialNotSupportedError = class extends Error {
6027
+ constructor(message) {
6028
+ super(message);
6029
+ this.name = "SpatialNotSupportedError";
6030
+ }
6031
+ };
6032
+ Point = class _Point {
6033
+ lon;
6034
+ lat;
6035
+ srid;
6036
+ constructor(lon, lat, srid = DEFAULT_SRID) {
6037
+ if (typeof lon === "boolean" || typeof lat === "boolean" || typeof srid === "boolean") {
6038
+ throw new TypeError("Point longitude, latitude and SRID must be numbers");
6039
+ }
6040
+ this.lon = Number(lon);
6041
+ this.lat = Number(lat);
6042
+ this.srid = Number(srid);
6043
+ if (!Number.isFinite(this.lon) || !Number.isFinite(this.lat) || !Number.isInteger(this.srid)) {
6044
+ throw new TypeError("Point longitude and latitude must be finite numbers and SRID must be an integer");
6045
+ }
6046
+ if (this.srid === DEFAULT_SRID) {
6047
+ if (this.lon < -180 || this.lon > 180) throw new RangeError(`Point longitude ${this.lon} is outside -180..180; Tina4 uses longitude, latitude order`);
6048
+ if (this.lat < -90 || this.lat > 90) throw new RangeError(`Point latitude ${this.lat} is outside -90..90; Tina4 uses longitude, latitude order`);
6049
+ }
6050
+ Object.freeze(this);
6051
+ }
6052
+ get wkt() {
6053
+ return `POINT(${formatCoordinate(this.lon)} ${formatCoordinate(this.lat)})`;
6054
+ }
6055
+ get ewkt() {
6056
+ return `SRID=${this.srid};${this.wkt}`;
6057
+ }
6058
+ get geojson() {
6059
+ return { type: "Point", coordinates: [this.lon, this.lat] };
6060
+ }
6061
+ toJSON() {
6062
+ return this.geojson;
6063
+ }
6064
+ toArray() {
6065
+ return [this.lon, this.lat];
6066
+ }
6067
+ static parse(value, srid = DEFAULT_SRID) {
6068
+ if (value instanceof _Point) return value;
6069
+ if (Array.isArray(value)) {
6070
+ if (value.length < 2) throw new TypeError("Point coordinate pair needs longitude and latitude");
6071
+ return new _Point(value[0], value[1], srid);
6072
+ }
6073
+ if (value && typeof value === "object" && !(value instanceof Uint8Array)) {
6074
+ return _Point.fromGeoJson(value, srid);
6075
+ }
6076
+ if (value instanceof Uint8Array) return _Point.fromWkb(value, srid);
6077
+ if (typeof value === "string") {
6078
+ const text = value.trim();
6079
+ 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);
6080
+ if (match) return new _Point(match[2], match[3], match[1] ? Number(match[1]) : srid);
6081
+ if (text.length >= 42 && text.length % 2 === 0 && /^[0-9a-f]+$/i.test(text)) {
6082
+ return _Point.fromWkb(Uint8Array.from(Buffer.from(text, "hex")), srid);
6083
+ }
6084
+ }
6085
+ throw new TypeError("Point must be Point, [longitude, latitude], WKT/EWKT, GeoJSON or WKB/EWKB");
6086
+ }
6087
+ static geometryBinding(value, srid = DEFAULT_SRID) {
6088
+ if (value instanceof _Point || Array.isArray(value)) return [_Point.parse(value, srid).ewkt, "ewkt"];
6089
+ if (value && typeof value === "object") {
6090
+ const candidate = value;
6091
+ const geometry = String(candidate.type).toLowerCase() === "feature" ? candidate.geometry : candidate;
6092
+ const allowed = /* @__PURE__ */ new Set(["point", "linestring", "polygon", "multipoint", "multilinestring", "multipolygon", "geometrycollection"]);
6093
+ if (!geometry || !allowed.has(String(geometry.type).toLowerCase())) throw new TypeError("GeoJSON geometry has an unsupported type");
6094
+ return [JSON.stringify(geometry), "geojson"];
6095
+ }
6096
+ if (typeof value === "string" && /^\s*(?:SRID\s*=\s*\d+\s*;\s*)?(?:POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON|GEOMETRYCOLLECTION)\b/i.test(value)) {
6097
+ return [/^\s*SRID/i.test(value) ? value.trim() : `SRID=${srid};${value.trim()}`, "ewkt"];
6098
+ }
6099
+ throw new TypeError("Geometry must be Point, coordinate pair, WKT/EWKT or GeoJSON");
6100
+ }
6101
+ static fromGeoJson(data, srid) {
6102
+ const geometry = String(data.type).toLowerCase() === "feature" ? data.geometry : data;
6103
+ if (!geometry || String(geometry.type).toLowerCase() !== "point") throw new TypeError("Point GeoJSON type must be Point");
6104
+ const coordinates = geometry.coordinates;
6105
+ if (!Array.isArray(coordinates) || coordinates.length < 2) throw new TypeError("Point GeoJSON coordinates must be [longitude, latitude]");
6106
+ return new _Point(coordinates[0], coordinates[1], srid);
6107
+ }
6108
+ static fromWkb(raw, srid) {
6109
+ if (raw.byteLength < 21) throw new TypeError("Point WKB is too short");
6110
+ const view = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
6111
+ const little = raw[0] === 1;
6112
+ const typeWord = view.getUint32(1, little);
6113
+ let offset = 5;
6114
+ if ((typeWord & 536870912) !== 0) {
6115
+ srid = view.getUint32(5, little);
6116
+ offset = 9;
6117
+ }
6118
+ const code = (typeWord & ~(536870912 | 1073741824 | 2147483648)) % 1e3;
6119
+ if (code !== 1 || raw.byteLength < offset + 16) throw new TypeError("WKB geometry is not a Point");
6120
+ return new _Point(view.getFloat64(offset, little), view.getFloat64(offset + 8, little), srid);
6121
+ }
6122
+ };
6123
+ }
6124
+ });
6125
+
6017
6126
  // ../orm/src/sqlTranslator.ts
6018
6127
  var SQLTranslator, QueryCache;
6019
6128
  var init_sqlTranslator = __esm({
6020
6129
  "../orm/src/sqlTranslator.ts"() {
6021
6130
  "use strict";
6022
6131
  init_databaseUrl();
6132
+ init_point();
6023
6133
  SQLTranslator = class _SQLTranslator {
6134
+ static SPATIAL_ENGINES = /* @__PURE__ */ new Set(["postgres", "postgresql"]);
6135
+ static SPATIAL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
6136
+ static requireSpatial(engine, feature) {
6137
+ const name = String(engine || "unknown").toLowerCase();
6138
+ if (!_SQLTranslator.SPATIAL_ENGINES.has(name)) {
6139
+ throw new SpatialNotSupportedError(
6140
+ `${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.`
6141
+ );
6142
+ }
6143
+ return name;
6144
+ }
6145
+ static spatialIdentifier(name, what = "column") {
6146
+ if (!_SQLTranslator.SPATIAL_IDENTIFIER.test(name)) throw new TypeError(`Spatial ${what} is not a valid SQL identifier: ${name}`);
6147
+ return name;
6148
+ }
6149
+ static pointColumnType(engine, srid = DEFAULT_SRID) {
6150
+ _SQLTranslator.requireSpatial(engine, "PointField");
6151
+ return `geography(Point,${srid})`;
6152
+ }
6153
+ static spatialIndex(engine, table2, column2) {
6154
+ _SQLTranslator.requireSpatial(engine, "spatial index creation");
6155
+ table2 = _SQLTranslator.spatialIdentifier(table2, "table");
6156
+ column2 = _SQLTranslator.spatialIdentifier(column2);
6157
+ return `CREATE INDEX IF NOT EXISTS ${table2.replaceAll(".", "_")}_${column2}_gist ON ${table2} USING GIST (${column2})`;
6158
+ }
6159
+ static pointLiteral(engine, srid = DEFAULT_SRID) {
6160
+ _SQLTranslator.requireSpatial(engine, "spatial predicates");
6161
+ return `ST_SetSRID(ST_MakePoint(?, ?), ${srid})::geography`;
6162
+ }
6163
+ static withinDistance(engine, column2, srid = DEFAULT_SRID) {
6164
+ return `ST_DWithin(${_SQLTranslator.spatialIdentifier(column2)}, ${_SQLTranslator.pointLiteral(engine, srid)}, ?)`;
6165
+ }
6166
+ static distance(engine, column2, srid = DEFAULT_SRID) {
6167
+ return `ST_Distance(${_SQLTranslator.spatialIdentifier(column2)}, ${_SQLTranslator.pointLiteral(engine, srid)})`;
6168
+ }
6169
+ static distanceAs(engine, column2, alias, srid = DEFAULT_SRID) {
6170
+ return `${_SQLTranslator.distance(engine, column2, srid)} AS ${_SQLTranslator.spatialIdentifier(alias, "result alias")}`;
6171
+ }
6172
+ static geometryLiteral(engine, form, srid = DEFAULT_SRID) {
6173
+ _SQLTranslator.requireSpatial(engine, "spatial predicates");
6174
+ return form === "ewkt" ? "ST_GeogFromText(?)" : `ST_SetSRID(ST_GeomFromGeoJSON(?), ${srid})::geography`;
6175
+ }
6176
+ static intersects(engine, column2, form = "ewkt", srid = DEFAULT_SRID) {
6177
+ return `ST_Intersects(${_SQLTranslator.spatialIdentifier(column2)}, ${_SQLTranslator.geometryLiteral(engine, form, srid)})`;
6178
+ }
6179
+ static bbox(engine, column2, srid = DEFAULT_SRID) {
6180
+ _SQLTranslator.requireSpatial(engine, "bbox");
6181
+ return `ST_Intersects(${_SQLTranslator.spatialIdentifier(column2)}, ST_MakeEnvelope(?, ?, ?, ?, ${srid})::geography)`;
6182
+ }
6024
6183
  /**
6025
6184
  * Convert LIMIT/OFFSET to Firebird ROWS...TO syntax.
6026
6185
  *
@@ -7674,6 +7833,8 @@ function fieldTypeToPostgres(def) {
7674
7833
  return "TEXT";
7675
7834
  case "json":
7676
7835
  return "JSONB";
7836
+ case "point":
7837
+ return SQLTranslator.pointColumnType("postgres", def.srid ?? 4326);
7677
7838
  case "string":
7678
7839
  return def.maxLength ? `VARCHAR(${def.maxLength})` : "VARCHAR(255)";
7679
7840
  default:
@@ -12190,7 +12351,9 @@ function buildAddColumnSql(adapter, table2, colName, def) {
12190
12351
  return sql;
12191
12352
  }
12192
12353
  function mt(db) {
12193
- return engineOf(db) === "mysql" ? `\`${MIGRATION_TABLE}\`` : `"${MIGRATION_TABLE}"`;
12354
+ const engine = engineOf(db);
12355
+ if (engine === "firebird") return MIGRATION_TABLE;
12356
+ return engine === "mysql" ? `\`${MIGRATION_TABLE}\`` : `"${MIGRATION_TABLE}"`;
12194
12357
  }
12195
12358
  function deriveDescription(name) {
12196
12359
  return name.replace(/^\d+_/, "").replace(/_/g, " ");
@@ -12213,9 +12376,9 @@ async function ensureMigrationTableOn(db) {
12213
12376
  id INTEGER NOT NULL PRIMARY KEY,
12214
12377
  migration_name VARCHAR(500) NOT NULL UNIQUE,
12215
12378
  description VARCHAR(500),
12216
- batch INTEGER NOT NULL DEFAULT 1,
12379
+ batch INTEGER DEFAULT 1 NOT NULL,
12217
12380
  executed_at VARCHAR(50) NOT NULL,
12218
- passed INTEGER NOT NULL DEFAULT 1
12381
+ passed INTEGER DEFAULT 1 NOT NULL
12219
12382
  )`);
12220
12383
  } else {
12221
12384
  const idCol = migrationIdColumn(db);
@@ -12312,7 +12475,7 @@ async function recordApplied(db, name, batch, passed = 1) {
12312
12475
  "SELECT GEN_ID(GEN_TINA4_MIGRATION_ID, 1) AS NEXT_ID FROM RDB$DATABASE"
12313
12476
  );
12314
12477
  insertCols.unshift("id");
12315
- values.unshift(rows[0]?.NEXT_ID ?? 1);
12478
+ values.unshift(rows[0]?.next_id ?? 1);
12316
12479
  }
12317
12480
  const placeholders = insertCols.map(() => "?").join(", ");
12318
12481
  await adapterExecute(
@@ -13297,10 +13460,13 @@ var init_queryBuilder = __esm({
13297
13460
  "use strict";
13298
13461
  init_database();
13299
13462
  init_databaseResult();
13463
+ init_point();
13464
+ init_sqlTranslator();
13300
13465
  QueryBuilder = class _QueryBuilder {
13301
13466
  table;
13302
13467
  db;
13303
13468
  columns = ["*"];
13469
+ selectParams = [];
13304
13470
  wheres = [];
13305
13471
  params = [];
13306
13472
  joinClauses = [];
@@ -13308,14 +13474,17 @@ var init_queryBuilder = __esm({
13308
13474
  havings = [];
13309
13475
  havingParams = [];
13310
13476
  orderByCols = [];
13477
+ orderByParams = [];
13478
+ primaryKey;
13311
13479
  limitVal;
13312
13480
  offsetVal;
13313
13481
  /**
13314
13482
  * Private constructor — use static factory methods.
13315
13483
  */
13316
- constructor(table2, db) {
13484
+ constructor(table2, db, primaryKey) {
13317
13485
  this.table = table2;
13318
13486
  this.db = db;
13487
+ this.primaryKey = primaryKey;
13319
13488
  }
13320
13489
  /**
13321
13490
  * Create a QueryBuilder for a table.
@@ -13324,8 +13493,8 @@ var init_queryBuilder = __esm({
13324
13493
  * @param db - Optional database adapter.
13325
13494
  * @returns A new QueryBuilder instance.
13326
13495
  */
13327
- static fromTable(tableName, db) {
13328
- return new _QueryBuilder(tableName, db);
13496
+ static fromTable(tableName, db, primaryKey) {
13497
+ return new _QueryBuilder(tableName, db, primaryKey);
13329
13498
  }
13330
13499
  /**
13331
13500
  * Set the columns to select.
@@ -13336,6 +13505,7 @@ var init_queryBuilder = __esm({
13336
13505
  select(...cols) {
13337
13506
  if (cols.length > 0) {
13338
13507
  this.columns = cols;
13508
+ this.selectParams = [];
13339
13509
  }
13340
13510
  return this;
13341
13511
  }
@@ -13417,6 +13587,41 @@ var init_queryBuilder = __esm({
13417
13587
  this.orderByCols.push(expression);
13418
13588
  return this;
13419
13589
  }
13590
+ withinDistance(column2, pointValue, radiusMetres, srid = DEFAULT_SRID) {
13591
+ const radius = Number(radiusMetres);
13592
+ if (!Number.isFinite(radius) || radius < 0) throw new RangeError("Spatial radius must be finite and greater than or equal to zero");
13593
+ const point = Point.parse(pointValue, srid);
13594
+ return this.where(SQLTranslator.withinDistance(this.engine(), column2, point.srid), [point.lon, point.lat, radius]);
13595
+ }
13596
+ intersects(column2, geometry, srid = DEFAULT_SRID) {
13597
+ const [bound, form] = Point.geometryBinding(geometry, srid);
13598
+ return this.where(SQLTranslator.intersects(this.engine(), column2, form, srid), [bound]);
13599
+ }
13600
+ bbox(column2, minLon, minLat, maxLon, maxLat, srid = DEFAULT_SRID) {
13601
+ const values = [minLon, minLat, maxLon, maxLat].map(Number);
13602
+ if (!values.every(Number.isFinite)) throw new TypeError("Bounding-box coordinates must be finite numbers");
13603
+ const [west, south, east, north] = values;
13604
+ new Point(west, south, srid);
13605
+ new Point(east, north, srid);
13606
+ if (west > east || south > north) throw new RangeError("Bounding box must be ordered west, south, east, north");
13607
+ return this.where(SQLTranslator.bbox(this.engine(), column2, srid), values);
13608
+ }
13609
+ selectDistance(column2, pointValue, alias = "distance", srid = DEFAULT_SRID) {
13610
+ const point = Point.parse(pointValue, srid);
13611
+ this.columns.push(SQLTranslator.distanceAs(this.engine(), column2, alias, point.srid));
13612
+ this.selectParams.push(point.lon, point.lat);
13613
+ return this;
13614
+ }
13615
+ orderByDistance(column2, pointValue, direction = "ASC", srid = DEFAULT_SRID) {
13616
+ const order = direction.toUpperCase();
13617
+ if (order !== "ASC" && order !== "DESC") throw new TypeError("Distance order direction must be ASC or DESC");
13618
+ if (!this.primaryKey) throw new Error("Stable spatial ordering needs a primary key; use BaseModel.query() or pass one to fromTable()");
13619
+ const point = Point.parse(pointValue, srid);
13620
+ this.orderByCols.push(`${SQLTranslator.distance(this.engine(), column2, point.srid)} ${order}`);
13621
+ this.orderByParams.push(point.lon, point.lat);
13622
+ this.orderByCols.push(`${SQLTranslator.spatialIdentifier(this.primaryKey, "primary key")} ASC`);
13623
+ return this;
13624
+ }
13420
13625
  /**
13421
13626
  * Set LIMIT and optional OFFSET.
13422
13627
  *
@@ -13482,7 +13687,7 @@ var init_queryBuilder = __esm({
13482
13687
  async get() {
13483
13688
  this.ensureDb();
13484
13689
  const sql = this.toSql();
13485
- const allParams = [...this.params, ...this.havingParams];
13690
+ const allParams = [...this.selectParams, ...this.params, ...this.havingParams, ...this.orderByParams];
13486
13691
  const queryParams = allParams.length > 0 ? allParams : void 0;
13487
13692
  const rows = await adapterFetch(
13488
13693
  this.db,
@@ -13510,7 +13715,7 @@ var init_queryBuilder = __esm({
13510
13715
  async first() {
13511
13716
  this.ensureDb();
13512
13717
  const sql = this.toSql();
13513
- const allParams = [...this.params, ...this.havingParams];
13718
+ const allParams = [...this.selectParams, ...this.params, ...this.havingParams, ...this.orderByParams];
13514
13719
  return adapterFetchOne(
13515
13720
  this.db,
13516
13721
  sql,
@@ -13525,9 +13730,18 @@ var init_queryBuilder = __esm({
13525
13730
  async count() {
13526
13731
  this.ensureDb();
13527
13732
  const original = this.columns;
13733
+ const originalSelectParams = this.selectParams;
13734
+ const originalOrder = this.orderByCols;
13735
+ const originalOrderParams = this.orderByParams;
13528
13736
  this.columns = ["COUNT(*) as cnt"];
13737
+ this.selectParams = [];
13738
+ this.orderByCols = [];
13739
+ this.orderByParams = [];
13529
13740
  const sql = this.toSql();
13530
13741
  this.columns = original;
13742
+ this.selectParams = originalSelectParams;
13743
+ this.orderByCols = originalOrder;
13744
+ this.orderByParams = originalOrderParams;
13531
13745
  const allParams = [...this.params, ...this.havingParams];
13532
13746
  const row = await adapterFetchOne(
13533
13747
  this.db,
@@ -13712,6 +13926,10 @@ var init_queryBuilder = __esm({
13712
13926
  }
13713
13927
  }
13714
13928
  }
13929
+ engine() {
13930
+ this.ensureDb();
13931
+ return this.db.getDatabaseType();
13932
+ }
13715
13933
  };
13716
13934
  }
13717
13935
  });
@@ -13735,6 +13953,11 @@ function toDbFieldValue(def, value) {
13735
13953
  if (def?.type === "json" && value !== null && value !== void 0 && typeof value !== "string") {
13736
13954
  return JSON.stringify(value);
13737
13955
  }
13956
+ if (def?.type === "point" && value !== null && value !== void 0) {
13957
+ const point = Point.parse(value, def.srid ?? DEFAULT_SRID);
13958
+ if (point.srid !== (def.srid ?? DEFAULT_SRID)) throw new TypeError(`Point field expects SRID ${def.srid ?? DEFAULT_SRID}; received ${point.srid}`);
13959
+ return point.ewkt;
13960
+ }
13738
13961
  return value;
13739
13962
  }
13740
13963
  function fromDbFieldValue(def, value) {
@@ -13745,6 +13968,11 @@ function fromDbFieldValue(def, value) {
13745
13968
  return value;
13746
13969
  }
13747
13970
  }
13971
+ if (def?.type === "point" && value !== null && value !== void 0) {
13972
+ const point = Point.parse(value, def.srid ?? DEFAULT_SRID);
13973
+ if (point.srid !== (def.srid ?? DEFAULT_SRID)) throw new TypeError(`Point field expects SRID ${def.srid ?? DEFAULT_SRID}; received ${point.srid}`);
13974
+ return point;
13975
+ }
13748
13976
  return value;
13749
13977
  }
13750
13978
  function _pluralRelKeys() {
@@ -13780,6 +14008,7 @@ var init_baseModel = __esm({
13780
14008
  init_sqlite();
13781
14009
  init_sqlTranslator();
13782
14010
  init_index();
14011
+ init_point();
13783
14012
  _fkRegistry = /* @__PURE__ */ new Map();
13784
14013
  EAGER_IN_CHUNK = 500;
13785
14014
  modelQueryCache = new QueryCache({ defaultTtl: 0, maxSize: 500 });
@@ -13835,7 +14064,9 @@ var init_baseModel = __esm({
13835
14064
  for (const [name, def] of Object.entries(fields0)) {
13836
14065
  if (def.default === void 0) continue;
13837
14066
  let dv = typeof def.default === "function" ? def.default() : def.default;
13838
- if (dv !== null && typeof dv === "object") dv = structuredClone(dv);
14067
+ if (def.type === "point" && dv !== null && dv !== void 0) {
14068
+ dv = fromDbFieldValue(def, dv);
14069
+ } else if (dv !== null && typeof dv === "object") dv = structuredClone(dv);
13839
14070
  this[name] = dv;
13840
14071
  }
13841
14072
  if (data) {
@@ -13937,7 +14168,7 @@ var init_baseModel = __esm({
13937
14168
  * @returns A QueryBuilder instance bound to this model's table and database.
13938
14169
  */
13939
14170
  static query() {
13940
- return QueryBuilder.fromTable(this.tableName, this.getDb());
14171
+ return QueryBuilder.fromTable(this.tableName, this.getDb(), this.getPkColumn());
13941
14172
  }
13942
14173
  /**
13943
14174
  * Get the database adapter for this model.
@@ -14390,7 +14621,7 @@ var init_baseModel = __esm({
14390
14621
  for (const key of Object.keys(ModelClass.fields)) {
14391
14622
  if (this[key] !== void 0) {
14392
14623
  const outKey = case_ === "snake" ? ModelClass.fieldMapping[key] ?? key : key;
14393
- result[outKey] = this[key];
14624
+ result[outKey] = this[key] instanceof Point ? this[key].geojson : this[key];
14394
14625
  }
14395
14626
  }
14396
14627
  if (ModelClass.softDelete && this.is_deleted !== void 0) {
@@ -14450,6 +14681,19 @@ var init_baseModel = __esm({
14450
14681
  }
14451
14682
  return result;
14452
14683
  }
14684
+ toFeature(geometryField, include) {
14685
+ const ModelClass = this.constructor;
14686
+ const pointFields = Object.entries(ModelClass.fields).filter(([, def]) => def.type === "point").map(([name]) => name);
14687
+ const field = geometryField ?? pointFields[0];
14688
+ if (!field || !pointFields.includes(field)) throw new Error("toFeature() needs a declared point field");
14689
+ const properties = this.toDict(include, "camel");
14690
+ const geometry = properties[field] ?? null;
14691
+ delete properties[field];
14692
+ return { type: "Feature", geometry, properties };
14693
+ }
14694
+ static featureCollection(models, geometryField, include) {
14695
+ return { type: "FeatureCollection", features: models.map((model) => model.toFeature(geometryField, include)) };
14696
+ }
14453
14697
  /**
14454
14698
  * Convert to an associative object (alias for toDict).
14455
14699
  */
@@ -14500,7 +14744,10 @@ var init_baseModel = __esm({
14500
14744
  */
14501
14745
  static async createTable() {
14502
14746
  const db = this.getDb();
14503
- if (await adapterTableExists(db, this.tableName)) return true;
14747
+ const pointFields = Object.entries(this.fields).filter(([, def]) => def.type === "point");
14748
+ const engine = db.getDatabaseType();
14749
+ if (pointFields.length > 0) SQLTranslator.requireSpatial(engine, "PointField");
14750
+ if (await adapterTableExists(db, this.tableName)) return this.createSpatialIndexes(db, pointFields);
14504
14751
  if (typeof db.createTable === "function" || typeof db.createTableAsync === "function") {
14505
14752
  const mappedFields = {};
14506
14753
  for (const [fieldName, def] of Object.entries(this.fields)) {
@@ -14516,7 +14763,7 @@ var init_baseModel = __esm({
14516
14763
  mappedFields["is_deleted"] = { type: "integer", default: 0 };
14517
14764
  }
14518
14765
  await adapterCreateTable(db, this.tableName, mappedFields);
14519
- return true;
14766
+ return this.createSpatialIndexes(db, pointFields);
14520
14767
  }
14521
14768
  const typeMap = {
14522
14769
  integer: "INTEGER",
@@ -14563,6 +14810,14 @@ var init_baseModel = __esm({
14563
14810
  }
14564
14811
  return true;
14565
14812
  }
14813
+ static async createSpatialIndexes(db, fields) {
14814
+ for (const [fieldName, def] of fields) {
14815
+ SQLTranslator.pointColumnType(db.getDatabaseType(), def.srid ?? DEFAULT_SRID);
14816
+ if (def.spatialIndex === false) continue;
14817
+ await adapterExecute(db, SQLTranslator.spatialIndex(db.getDatabaseType(), this.tableName, this.getDbColumn(fieldName)));
14818
+ }
14819
+ return true;
14820
+ }
14566
14821
  /**
14567
14822
  * Find a record by primary key or throw an error if not found.
14568
14823
  */
@@ -14634,15 +14889,25 @@ var init_baseModel = __esm({
14634
14889
  /**
14635
14890
  * Invalidate every cached query that touches this model's table.
14636
14891
  *
14637
- * Tag-scoped, NOT a wholesale flush: a cached JOIN on another model that reads
14638
- * this table is busted too (it carries this table's tag), while a query that
14639
- * never touches this table is left intact. Called after every ORM write
14640
- * (save/delete/forceDelete/restore) so a read-after-write never serves a
14641
- * stale/deleted row (CACHE-DEC-01).
14892
+ * Tag-scoped in the ORM layer (a cached JOIN on another model that reads
14893
+ * this table is busted too because it carries this table's tag; a query
14894
+ * that never touches this table is left intact), then cascaded to the
14895
+ * DB layer on this model's bound connection so an out-of-band write /
14896
+ * deliberate refresh / race-with-another-process cannot leave stale rows
14897
+ * in db.fetch()'s persistent cache. Called after every ORM write
14898
+ * (save/delete/forceDelete/restore) so a read-after-write never serves
14899
+ * a stale/deleted row (CACHE-DEC-01). PY-06-22 (3.13.105) added the
14900
+ * DB-layer cascade -- previously the two cache layers disagreed under
14901
+ * TINA4_AUTO_CACHING=true + TINA4_DB_CACHE=true.
14642
14902
  */
14643
14903
  static clearCache() {
14644
14904
  const ModelClass = this;
14645
14905
  modelQueryCache.clearTag((ModelClass.tableName ?? "").toLowerCase());
14906
+ try {
14907
+ const db = ModelClass.getDb();
14908
+ if (typeof db?.cacheClear === "function") db.cacheClear();
14909
+ } catch {
14910
+ }
14646
14911
  }
14647
14912
  /**
14648
14913
  * Execute a raw SQL SELECT and return results as model instances.
@@ -17210,6 +17475,7 @@ __export(src_exports, {
17210
17475
  CachedDatabaseAdapter: () => CachedDatabaseAdapter,
17211
17476
  Cursor: () => Cursor,
17212
17477
  DEFAULT_DATABASE_CONNECT_TIMEOUT_SECONDS: () => DEFAULT_DATABASE_CONNECT_TIMEOUT_SECONDS,
17478
+ DEFAULT_SRID: () => DEFAULT_SRID,
17213
17479
  Database: () => Database,
17214
17480
  DatabaseResult: () => DatabaseResult,
17215
17481
  DatabaseUrl: () => DatabaseUrl,
@@ -17225,6 +17491,7 @@ __export(src_exports, {
17225
17491
  NOT_REQUIRED_ON_ADAPTER: () => NOT_REQUIRED_ON_ADAPTER,
17226
17492
  ObjectId: () => ObjectId,
17227
17493
  OdbcAdapter: () => OdbcAdapter,
17494
+ Point: () => Point,
17228
17495
  PostgresAdapter: () => PostgresAdapter,
17229
17496
  QueryBuilder: () => QueryBuilder,
17230
17497
  QueryCache: () => QueryCache,
@@ -17237,6 +17504,7 @@ __export(src_exports, {
17237
17504
  S3Storage: () => S3Storage,
17238
17505
  SQLTranslator: () => SQLTranslator,
17239
17506
  SQLiteAdapter: () => SQLiteAdapter,
17507
+ SpatialNotSupportedError: () => SpatialNotSupportedError,
17240
17508
  SqliteCollection: () => SqliteCollection,
17241
17509
  SqliteDatabase: () => SqliteDatabase,
17242
17510
  adapterColumns: () => adapterColumns,
@@ -17331,6 +17599,7 @@ var init_src = __esm({
17331
17599
  init_baseModel();
17332
17600
  init_queryBuilder();
17333
17601
  init_sqlTranslator();
17602
+ init_point();
17334
17603
  init_connectTimeout();
17335
17604
  init_cachedDatabase();
17336
17605
  init_fakeData2();
@@ -19520,6 +19789,12 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
19520
19789
  }
19521
19790
  }
19522
19791
  if (!resolvedToken) {
19792
+ const sso = req2.session?.get?.("_tina4_sso");
19793
+ const identity = sso?.identity;
19794
+ if (identity?.issuer && identity?.subject) {
19795
+ req2.user = identity;
19796
+ return false;
19797
+ }
19523
19798
  const sessionToken = req2.session?.get?.("token");
19524
19799
  if (sessionToken && validToken(sessionToken)) {
19525
19800
  resolvedToken = sessionToken;
@@ -27831,17 +28106,67 @@ var init_mongoBackend = __esm({
27831
28106
  process.stdout.write("__OK__");
27832
28107
  }
27833
28108
  else if (operation === "retry") {
27834
- // Explicit manual re-queue (always re-enqueues). data = JSON
27835
- // { id, delaySeconds }.
28109
+ // Explicit manual re-queue. Serves BOTH Queue.retry(id) (revive
28110
+ // a dead-letter job) AND job.retry() (manual re-queue of a live
28111
+ // reserved/pending job) so the Mongo backend matches
28112
+ // LiteBackend's dual behaviour.
28113
+ //
28114
+ // 1) DL revival (Queue.retry(id) after fail exhausted retries).
28115
+ // Pre-3.13.105 this branch was BROKEN: the search filter was
28116
+ // { queue: queueName, id, status: "failed" } -- three separate
28117
+ // reasons it could never match. dead_letter() inserts under
28118
+ // queueName + ".dead_letter" (not queueName), carries
28119
+ // status "dead" (not "failed"), and the original under
28120
+ // queueName was already acked to "completed" by the time the
28121
+ // DL was written. Now we look up in the DL namespace by id,
28122
+ // delete the DL doc first (so an interrupted retry never
28123
+ // leaves both a DL and a fresh pending doc), and upsert the
28124
+ // original back to pending -- re-hydrating if the original
28125
+ // was purged (housekeeping) so a retry always works.
28126
+ // 2) Live-doc manual re-queue (job.retry() on a job the caller
28127
+ // just popped and wants back in pending). The live-doc path
28128
+ // is preserved from before 3.13.105.
28129
+ //
28130
+ // Returns __OK__ when either path acted; __NOT_FOUND__ when
28131
+ // neither the DL nor the live doc existed, so Queue.retry(id)
28132
+ // can now report the pre-3.13.105 blanket-true as false for
28133
+ // unknown ids. data = JSON { id, delaySeconds }.
27836
28134
  const info = JSON.parse(data);
28135
+ const dlTopic = queueName + ".dead_letter";
28136
+ const now = new Date().toISOString();
27837
28137
  const avail = info.delaySeconds > 0
27838
28138
  ? new Date(Date.now() + info.delaySeconds * 1000).toISOString()
27839
- : new Date().toISOString();
27840
- await col.updateOne(
27841
- { queue: queueName, id: info.id },
27842
- { $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
27843
- );
27844
- process.stdout.write("__OK__");
28139
+ : now;
28140
+ const dlDoc = await col.findOne({ queue: dlTopic, id: info.id });
28141
+ if (dlDoc !== null) {
28142
+ await col.deleteOne({ _id: dlDoc._id });
28143
+ const payload = dlDoc.payload ?? {};
28144
+ const priority = dlDoc.priority ?? 0;
28145
+ await col.updateOne(
28146
+ { queue: queueName, id: info.id },
28147
+ {
28148
+ $set: {
28149
+ status: "pending",
28150
+ availableAt: avail,
28151
+ reservedAt: null,
28152
+ error: null,
28153
+ payload,
28154
+ priority,
28155
+ id: info.id,
28156
+ createdAt: dlDoc.createdAt ?? now,
28157
+ },
28158
+ $inc: { attempts: 1 },
28159
+ },
28160
+ { upsert: true },
28161
+ );
28162
+ process.stdout.write("__OK__");
28163
+ } else {
28164
+ const result = await col.updateOne(
28165
+ { queue: queueName, id: info.id },
28166
+ { $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
28167
+ );
28168
+ process.stdout.write(result.matchedCount > 0 ? "__OK__" : "__NOT_FOUND__");
28169
+ }
27845
28170
  }
27846
28171
  else if (operation === "deadLetters") {
27847
28172
  const docs = await col.find({ queue: queueName + ".dead_letter" }).toArray();
@@ -27886,10 +28211,20 @@ var init_mongoBackend = __esm({
27886
28211
  process.stdout.write(String(revived));
27887
28212
  }
27888
28213
  else if (operation === "purge") {
27889
- // Delete docs by status (default: all for the topic). data = JSON { status }.
28214
+ // Delete docs by status (default: every doc for the topic).
28215
+ // Pre-3.13.105 this filtered by { queue: queueName, status } for
28216
+ // EVERY status -- correct for pending/reserved/completed, wrong
28217
+ // for the dead-letter states (dead/failed/dead_letter) which
28218
+ // live under queueName + ".dead_letter" and carry status "dead".
28219
+ // A purge("dead") therefore deleted nothing and returned 0.
28220
+ // data = JSON { status }.
27890
28221
  const info = data ? JSON.parse(data) : {};
27891
- const filter = { queue: queueName };
27892
- if (info.status) filter.status = info.status;
28222
+ const isDead = info.status && ["dead", "failed", "dead_letter"].includes(info.status);
28223
+ const filter = isDead
28224
+ ? { queue: queueName + ".dead_letter" }
28225
+ : (info.status
28226
+ ? { queue: queueName, status: info.status }
28227
+ : { queue: queueName });
27893
28228
  const res = await col.deleteMany(filter);
27894
28229
  process.stdout.write(String(res.deletedCount || 0));
27895
28230
  }
@@ -27986,9 +28321,15 @@ var init_mongoBackend = __esm({
27986
28321
  fail(queue, id, error, maxRetries, retryBackoff = 0) {
27987
28322
  this.execSync("fail", queue, JSON.stringify({ id, error, maxRetries, retryBackoff }));
27988
28323
  }
27989
- /** Explicit manual re-queue (always re-enqueues regardless of the retry limit). */
28324
+ /**
28325
+ * Revive a specific dead-letter job by id. Returns true if the DL was found
28326
+ * and revived, false otherwise (parity with LiteBackend.retry(queue, id)
28327
+ * and Python's mongo_backend.retry_job()). Pre-3.13.105 this returned void
28328
+ * and Queue.retry(id) reported success for every call, even for unknown ids.
28329
+ */
27990
28330
  retry(queue, id, delaySeconds = 0) {
27991
- this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
28331
+ const out = this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
28332
+ return out.includes("__OK__");
27992
28333
  }
27993
28334
  /** Jobs that exceeded max retries (the `<queue>.dead_letter` collection topic). */
27994
28335
  deadLetters(queue, maxRetries) {
@@ -28657,10 +28998,20 @@ var init_liteBackend = __esm({
28657
28998
  * Explicit re-queue requested by the caller (job.retry()).
28658
28999
  *
28659
29000
  * Always re-enqueues regardless of the retry limit — manual override,
28660
- * distinct from the automatic failJob() path.
29001
+ * distinct from the automatic failJob() path. Cleans up BOTH the
29002
+ * reservation record AND any dead-letter file for this id, so a caller
29003
+ * that iterates deadLetters() and calls .retry() on each doesn't leave
29004
+ * the failed/ directory carrying duplicates (PY-12-05, 3.13.105).
29005
+ * Aligns with retry(queue, jobId) which had always unlinked the
29006
+ * dead-letter file -- two spellings of the same intent that previously
29007
+ * diverged.
28661
29008
  */
28662
29009
  retryJob(queue, job, delaySeconds) {
28663
29010
  this.clearReservation(queue, job.id);
29011
+ try {
29012
+ unlinkSync7(join22(this.ensureFailedDir(queue), `${job.id}.queue-data`));
29013
+ } catch {
29014
+ }
28664
29015
  job.attempts = (job.attempts || 0) + 1;
28665
29016
  job.error = void 0;
28666
29017
  this.requeue(queue, job, delaySeconds ?? 0, void 0);
@@ -28859,7 +29210,19 @@ var init_queue = __esm({
28859
29210
  }
28860
29211
  }
28861
29212
  /**
28862
- * Count jobs filtered by status. Defaults to "pending".
29213
+ * Count jobs by status. Defaults to "pending".
29214
+ *
29215
+ * ``"pending"`` counts jobs waiting to be popped -- INCLUDES retryable-
29216
+ * but-attempted ones, because they live in the pending queue under the
29217
+ * auto-retry lifecycle (see failed()).
29218
+ * ``"reserved"`` counts jobs a consumer has popped but not yet
29219
+ * completed/failed (in-flight against the visibility timeout).
29220
+ * ``"completed"`` counts jobs the consumer has finished successfully.
29221
+ * ``"failed"``, ``"dead"``, ``"dead_letter"`` are ALIASES that all count
29222
+ * the dead-letter store -- jobs whose attempts >= maxRetries and that
29223
+ * have given up. Use deadLetters() to list them. Retryable-but-attempted
29224
+ * jobs are NOT counted by size("failed"); use failed() to list them or
29225
+ * size("pending") to include them in a total.
28863
29226
  */
28864
29227
  size(status2 = "pending") {
28865
29228
  const q = this.topic;
@@ -28909,13 +29272,17 @@ var init_queue = __esm({
28909
29272
  /**
28910
29273
  * Get jobs that failed at least once but are still being retried
28911
29274
  * (0 < attempts < maxRetries). These live in the pending queue under the
28912
- * auto-retry lifecycle; dead-lettered jobs are returned by deadLetters().
29275
+ * auto-retry lifecycle (fail() re-queues them with an incremented attempts
29276
+ * count and a retryBackoff delay) so pop() picks them up again. They are
29277
+ * NOT counted by size("failed") -- that alias counts the dead-letter store,
29278
+ * matching deadLetters(). To include retryable-failed jobs in a total, use
29279
+ * size("pending"). Terminal failures are returned by deadLetters().
28913
29280
  */
28914
29281
  failed() {
28915
- if (this.externalBackend?.failed) {
28916
- return this.externalBackend.failed(this.topic, this._maxRetries);
28917
- }
28918
- return this.liteBackend.failed(this.topic, this._maxRetries);
29282
+ const raw = this.externalBackend?.failed ? this.externalBackend.failed(this.topic, this._maxRetries) : this.liteBackend.failed(this.topic, this._maxRetries);
29283
+ return raw.map(
29284
+ (data) => createJob({ ...data, topic: data.topic ?? this.topic }, this)
29285
+ );
28919
29286
  }
28920
29287
  /**
28921
29288
  * Retry all dead letter jobs for this queue's topic.
@@ -28927,8 +29294,8 @@ var init_queue = __esm({
28927
29294
  retry(jobId, delaySeconds) {
28928
29295
  if (jobId) {
28929
29296
  if (this.externalBackend?.retry) {
28930
- this.externalBackend.retry(this.topic, jobId, delaySeconds);
28931
- return true;
29297
+ const result = this.externalBackend.retry(this.topic, jobId, delaySeconds);
29298
+ return result === void 0 ? true : Boolean(result);
28932
29299
  }
28933
29300
  return this.liteBackend.retry(this.topic, jobId, delaySeconds);
28934
29301
  }
@@ -28937,8 +29304,8 @@ var init_queue = __esm({
28937
29304
  let retried = false;
28938
29305
  for (const job of deadJobs) {
28939
29306
  if (this.externalBackend?.retry) {
28940
- this.externalBackend.retry(this.topic, job.id, delaySeconds);
28941
- retried = true;
29307
+ const result = this.externalBackend.retry(this.topic, job.id, delaySeconds);
29308
+ if (result === void 0 || Boolean(result)) retried = true;
28942
29309
  } else if (this.liteBackend.retry(this.topic, job.id, delaySeconds)) {
28943
29310
  retried = true;
28944
29311
  }
@@ -28946,13 +29313,28 @@ var init_queue = __esm({
28946
29313
  return retried;
28947
29314
  }
28948
29315
  /**
28949
- * Get dead letter jobs failed jobs that exceeded max retries.
29316
+ * Get jobs that exceeded max_retries -- terminal failures.
29317
+ *
29318
+ * Same set counted by size("failed") / size("dead") / size("dead_letter")
29319
+ * (three aliases for the dead-letter store). To LIST retryable-but-
29320
+ * attempted jobs (attempts > 0 AND attempts < maxRetries) that are still
29321
+ * being auto-retried, use failed() -- those live in the pending queue and
29322
+ * are NOT dead letters.
29323
+ *
29324
+ * Returns Job objects with the failure reason on ``.error`` (not raw dicts)
29325
+ * so callers can iterate uniformly with the rest of the queue API and, in
29326
+ * particular, call ``.retry()`` on each to manually revive it:
29327
+ *
29328
+ * for (const job of queue.deadLetters()) {
29329
+ * Log.warn(`revived ${job.id}: ${job.error}`);
29330
+ * job.retry();
29331
+ * }
28950
29332
  */
28951
29333
  deadLetters(maxRetries) {
28952
- if (this.externalBackend?.deadLetters) {
28953
- return this.externalBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
28954
- }
28955
- return this.liteBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
29334
+ const raw = this.externalBackend?.deadLetters ? this.externalBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries) : this.liteBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
29335
+ return raw.map(
29336
+ (data) => createJob({ ...data, topic: data.topic ?? this.topic }, this)
29337
+ );
28956
29338
  }
28957
29339
  /**
28958
29340
  * Delete messages by status (e.g. "completed", "failed", "dead").
@@ -34071,6 +34453,14 @@ function resolveSecuritySchemes() {
34071
34453
  const apiKeyIn = ["header", "query", "cookie"].includes(rawIn) ? rawIn : "header";
34072
34454
  schemes.apiKeyAuth = { type: "apiKey", name: apiKeyName, in: apiKeyIn };
34073
34455
  }
34456
+ const ssoIssuer = (process.env.TINA4_SSO_ISSUER ?? "").replace(/\/$/, "");
34457
+ if (ssoIssuer) {
34458
+ schemes.oidc = {
34459
+ type: "openIdConnect",
34460
+ openIdConnectUrl: `${ssoIssuer}/.well-known/openid-configuration`
34461
+ };
34462
+ schemes.ssoSession = { type: "apiKey", in: "cookie", name: "tina4_session" };
34463
+ }
34074
34464
  for (const [name, def] of Object.entries(registeredSchemes)) {
34075
34465
  schemes[name] = def;
34076
34466
  }
@@ -34252,7 +34642,9 @@ function generate(routes, models = []) {
34252
34642
  if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
34253
34643
  }
34254
34644
  } else if (routeRequiresAuth(route, method)) {
34255
- operation.security = sanitizeSecurity([{ [defaultScheme]: [] }], schemes);
34645
+ const requirements = [{ [defaultScheme]: [] }];
34646
+ if (defaultScheme === "bearerAuth" && schemes.ssoSession) requirements.push({ ssoSession: [] });
34647
+ operation.security = sanitizeSecurity(requirements, schemes);
34256
34648
  const responses = operation.responses;
34257
34649
  if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
34258
34650
  }
@@ -34566,6 +34958,298 @@ var init_src2 = __esm({
34566
34958
  }
34567
34959
  });
34568
34960
 
34961
+ // src/sso.ts
34962
+ var sso_exports = {};
34963
+ __export(sso_exports, {
34964
+ SSO: () => Sso,
34965
+ Sso: () => Sso,
34966
+ SsoError: () => SsoError
34967
+ });
34968
+ import { createHash as createHash9, randomBytes as randomBytes7, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
34969
+ var SsoError, Sso;
34970
+ var init_sso = __esm({
34971
+ "src/sso.ts"() {
34972
+ "use strict";
34973
+ SsoError = class extends Error {
34974
+ };
34975
+ Sso = class _Sso {
34976
+ static PENDING_KEY = "_tina4_sso_pending";
34977
+ static SESSION_KEY = "_tina4_sso";
34978
+ issuer;
34979
+ clientId;
34980
+ clientSecret;
34981
+ redirectUri;
34982
+ scopes;
34983
+ verify;
34984
+ postLogoutRedirectUri;
34985
+ claimMap;
34986
+ timeout;
34987
+ metadata = {};
34988
+ static mountedRouters = /* @__PURE__ */ new WeakSet();
34989
+ constructor(options = {}) {
34990
+ this.issuer = (options.issuer ?? process.env.TINA4_SSO_ISSUER ?? "").replace(/\/$/, "");
34991
+ this.clientId = options.clientId ?? process.env.TINA4_SSO_CLIENT_ID ?? "";
34992
+ this.clientSecret = options.clientSecret ?? process.env.TINA4_SSO_CLIENT_SECRET;
34993
+ this.redirectUri = options.redirectUri ?? process.env.TINA4_SSO_REDIRECT_URI ?? "";
34994
+ this.scopes = options.scopes ?? this.jsonEnv("TINA4_SSO_SCOPES", ["openid", "profile", "email"]);
34995
+ this.verify = options.verify ?? process.env.TINA4_SSO_VERIFY ?? "introspection";
34996
+ this.postLogoutRedirectUri = options.postLogoutRedirectUri ?? process.env.TINA4_SSO_POST_LOGOUT_REDIRECT_URI;
34997
+ this.claimMap = options.claimMap ?? this.jsonEnv("TINA4_SSO_CLAIM_MAP", {});
34998
+ this.timeout = options.timeout ?? 1e4;
34999
+ this.validateConfig();
35000
+ }
35001
+ static async fromIssuer(options = {}) {
35002
+ const value = new _Sso(options);
35003
+ await value.discover();
35004
+ return value;
35005
+ }
35006
+ static configured() {
35007
+ return ["TINA4_SSO_ISSUER", "TINA4_SSO_CLIENT_ID", "TINA4_SSO_REDIRECT_URI"].every((key) => Boolean(process.env[key]));
35008
+ }
35009
+ jsonEnv(name, fallback) {
35010
+ const raw = process.env[name];
35011
+ if (!raw) return fallback;
35012
+ try {
35013
+ return JSON.parse(raw);
35014
+ } catch {
35015
+ throw new SsoError(`${name} must be valid JSON`);
35016
+ }
35017
+ }
35018
+ static secureUrl(value, name) {
35019
+ let url;
35020
+ try {
35021
+ url = new URL(value);
35022
+ } catch {
35023
+ throw new SsoError(`${name} must be an absolute URL`);
35024
+ }
35025
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
35026
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
35027
+ throw new SsoError(`${name} must use HTTPS except on loopback`);
35028
+ }
35029
+ }
35030
+ validateConfig() {
35031
+ if (!this.issuer || !this.clientId || !this.redirectUri) {
35032
+ throw new SsoError("TINA4_SSO_ISSUER, TINA4_SSO_CLIENT_ID and TINA4_SSO_REDIRECT_URI are required");
35033
+ }
35034
+ _Sso.secureUrl(this.issuer, "issuer");
35035
+ _Sso.secureUrl(this.redirectUri, "redirect URI");
35036
+ if (!["introspection", "jwks"].includes(this.verify)) throw new SsoError("TINA4_SSO_VERIFY must be introspection or jwks");
35037
+ if (this.verify === "jwks") throw new SsoError("jwks verification requires an installed cryptography capability");
35038
+ if (this.verify === "introspection" && !this.clientSecret) throw new SsoError("introspection verification requires TINA4_SSO_CLIENT_SECRET");
35039
+ if (!Array.isArray(this.scopes) || !this.scopes.includes("openid")) throw new SsoError("TINA4_SSO_SCOPES must be a list containing openid");
35040
+ }
35041
+ async requestJson(url, form, bearer, basic = false) {
35042
+ const headers = { Accept: "application/json" };
35043
+ let body;
35044
+ if (form) {
35045
+ const parameters = new URLSearchParams();
35046
+ for (const [key, value] of Object.entries(form)) parameters.set(key, String(value));
35047
+ body = parameters.toString();
35048
+ headers["Content-Type"] = "application/x-www-form-urlencoded";
35049
+ }
35050
+ if (bearer) headers.Authorization = `Bearer ${bearer}`;
35051
+ if (basic) headers.Authorization = `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString("base64")}`;
35052
+ const controller = new AbortController();
35053
+ const timer = setTimeout(() => controller.abort(), this.timeout);
35054
+ try {
35055
+ const response = await fetch(url, { method: form ? "POST" : "GET", headers, body, signal: controller.signal });
35056
+ if (!response.ok) throw new SsoError("OIDC provider request failed");
35057
+ const result = await response.json();
35058
+ if (!result || typeof result !== "object" || Array.isArray(result)) throw new SsoError("OIDC provider returned a non-object response");
35059
+ return result;
35060
+ } catch (error) {
35061
+ if (error instanceof SsoError) throw error;
35062
+ throw new SsoError("OIDC provider request failed");
35063
+ } finally {
35064
+ clearTimeout(timer);
35065
+ }
35066
+ }
35067
+ async discover(force = false) {
35068
+ if (Object.keys(this.metadata).length && !force) return { ...this.metadata };
35069
+ const result = await this.requestJson(`${this.issuer}/.well-known/openid-configuration`);
35070
+ if (result.issuer !== this.issuer) throw new SsoError("OIDC discovery issuer does not exactly match configuration");
35071
+ const required = ["authorization_endpoint", "token_endpoint"];
35072
+ if (this.verify === "introspection") required.push("introspection_endpoint");
35073
+ for (const key of required) {
35074
+ if (!result[key]) throw new SsoError(`OIDC discovery is missing ${key}`);
35075
+ _Sso.secureUrl(result[key], key);
35076
+ }
35077
+ this.metadata = result;
35078
+ return { ...result };
35079
+ }
35080
+ static safeReturn(value) {
35081
+ if (!value || !value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return "/";
35082
+ return [...value].some((char) => char.charCodeAt(0) < 32) ? "/" : value;
35083
+ }
35084
+ session(value) {
35085
+ return value?.session ?? value;
35086
+ }
35087
+ async login(requestOrSession, returnTo = "/") {
35088
+ const session = this.session(requestOrSession);
35089
+ if (!session) throw new SsoError("SSO login requires a Tina4 Session");
35090
+ const state = randomBytes7(32).toString("base64url");
35091
+ const nonce = randomBytes7(32).toString("base64url");
35092
+ const verifier = randomBytes7(64).toString("base64url");
35093
+ const challenge = createHash9("sha256").update(verifier).digest("base64url");
35094
+ session.set(_Sso.PENDING_KEY, { state, nonce, verifier, return_to: _Sso.safeReturn(returnTo), created_at: Math.floor(Date.now() / 1e3) });
35095
+ const metadata = await this.discover();
35096
+ const query = new URLSearchParams({
35097
+ client_id: this.clientId,
35098
+ redirect_uri: this.redirectUri,
35099
+ response_type: "code",
35100
+ scope: this.scopes.join(" "),
35101
+ state,
35102
+ nonce,
35103
+ code_challenge: challenge,
35104
+ code_challenge_method: "S256"
35105
+ });
35106
+ return `${metadata.authorization_endpoint}?${query}`;
35107
+ }
35108
+ static equal(left, right) {
35109
+ const a = Buffer.from(String(left ?? ""));
35110
+ const b = Buffer.from(String(right ?? ""));
35111
+ return a.length === b.length && timingSafeEqual3(a, b);
35112
+ }
35113
+ static jwtPayload(token) {
35114
+ try {
35115
+ return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString());
35116
+ } catch {
35117
+ throw new SsoError("provider returned an invalid ID token");
35118
+ }
35119
+ }
35120
+ async introspect(accessToken) {
35121
+ const metadata = await this.discover();
35122
+ const result = await this.requestJson(metadata.introspection_endpoint, { token: accessToken, token_type_hint: "access_token" }, void 0, true);
35123
+ if (result.active !== true || result.iss !== this.issuer) throw new SsoError("OIDC access token is inactive or has the wrong issuer");
35124
+ const audience = result.aud ?? result.client_id;
35125
+ const valid = (Array.isArray(audience) ? audience.includes(this.clientId) : audience === this.clientId) || result.client_id === this.clientId;
35126
+ if (!valid) throw new SsoError("OIDC token audience mismatch");
35127
+ return result;
35128
+ }
35129
+ claim(claims, configured, fallback) {
35130
+ let value = claims;
35131
+ for (const part of (configured ?? fallback).split(".")) value = value && typeof value === "object" ? value[part] : void 0;
35132
+ return value;
35133
+ }
35134
+ normalize(claims) {
35135
+ const subject = this.claim(claims, this.claimMap.subject, "sub");
35136
+ const issuer = this.claim(claims, this.claimMap.issuer, "iss") ?? this.issuer;
35137
+ if (!subject || issuer !== this.issuer) throw new SsoError("OIDC identity is missing a valid issuer or subject");
35138
+ const roles = [...this.claim(claims, this.claimMap.roles, "realm_access.roles") ?? [], ...claims.resource_access?.[this.clientId]?.roles ?? []];
35139
+ const groups = this.claim(claims, this.claimMap.groups, "groups") ?? [];
35140
+ return {
35141
+ issuer,
35142
+ subject,
35143
+ username: this.claim(claims, this.claimMap.username, "preferred_username") ?? null,
35144
+ email: this.claim(claims, this.claimMap.email, "email") ?? null,
35145
+ name: this.claim(claims, this.claimMap.name, "name") ?? null,
35146
+ roles: [...new Set(roles.map(String))].sort(),
35147
+ groups: [...new Set(groups.map(String))].sort()
35148
+ };
35149
+ }
35150
+ async callback(requestOrSession, query) {
35151
+ const session = this.session(requestOrSession);
35152
+ const values = query ?? requestOrSession?.query ?? {};
35153
+ const pending = session?.get(_Sso.PENDING_KEY);
35154
+ session?.delete(_Sso.PENDING_KEY);
35155
+ if (!pending || !values.code || !_Sso.equal(values.state, pending.state)) throw new SsoError("OIDC callback state is invalid or already consumed");
35156
+ if (Math.floor(Date.now() / 1e3) - Number(pending.created_at ?? 0) > 600) throw new SsoError("OIDC callback state has expired");
35157
+ const metadata = await this.discover();
35158
+ const tokens = await this.requestJson(metadata.token_endpoint, {
35159
+ grant_type: "authorization_code",
35160
+ code: values.code,
35161
+ redirect_uri: this.redirectUri,
35162
+ client_id: this.clientId,
35163
+ code_verifier: pending.verifier
35164
+ }, void 0, Boolean(this.clientSecret));
35165
+ if (!tokens.access_token || !tokens.id_token) throw new SsoError("OIDC token response is incomplete");
35166
+ if (this.verify === "jwks") throw new SsoError("JWKS verification requires an installed cryptography capability");
35167
+ const claims = await this.introspect(tokens.access_token);
35168
+ if (!_Sso.equal(_Sso.jwtPayload(tokens.id_token).nonce, pending.nonce)) throw new SsoError("OIDC ID token nonce mismatch");
35169
+ if (metadata.userinfo_endpoint) Object.assign(claims, await this.requestJson(metadata.userinfo_endpoint, void 0, tokens.access_token));
35170
+ const identity = this.normalize(claims);
35171
+ session.regenerate();
35172
+ session.set(_Sso.SESSION_KEY, {
35173
+ version: 1,
35174
+ identity,
35175
+ access_token: tokens.access_token,
35176
+ refresh_token: tokens.refresh_token,
35177
+ id_token: tokens.id_token,
35178
+ expires_at: Math.floor(Date.now() / 1e3) + Number(tokens.expires_in ?? 0)
35179
+ });
35180
+ return { identity, return_to: _Sso.safeReturn(pending.return_to) };
35181
+ }
35182
+ identity(requestOrSession) {
35183
+ const stored = this.session(requestOrSession)?.get(_Sso.SESSION_KEY);
35184
+ const identity = stored?.identity ?? null;
35185
+ if (identity && requestOrSession?.session) requestOrSession.user = identity;
35186
+ return identity;
35187
+ }
35188
+ async refresh(requestOrSession) {
35189
+ const session = this.session(requestOrSession);
35190
+ const stored = session?.get(_Sso.SESSION_KEY);
35191
+ if (!stored?.refresh_token) {
35192
+ session?.delete(_Sso.SESSION_KEY);
35193
+ throw new SsoError("OIDC session cannot be refreshed");
35194
+ }
35195
+ try {
35196
+ const metadata = await this.discover();
35197
+ const tokens = await this.requestJson(metadata.token_endpoint, {
35198
+ grant_type: "refresh_token",
35199
+ refresh_token: stored.refresh_token,
35200
+ client_id: this.clientId
35201
+ }, void 0, Boolean(this.clientSecret));
35202
+ const claims = await this.introspect(tokens.access_token);
35203
+ if (metadata.userinfo_endpoint) Object.assign(claims, await this.requestJson(metadata.userinfo_endpoint, void 0, tokens.access_token));
35204
+ const identity = this.normalize(claims);
35205
+ session.set(_Sso.SESSION_KEY, {
35206
+ ...stored,
35207
+ identity,
35208
+ access_token: tokens.access_token,
35209
+ refresh_token: tokens.refresh_token ?? stored.refresh_token,
35210
+ id_token: tokens.id_token ?? stored.id_token,
35211
+ expires_at: Math.floor(Date.now() / 1e3) + Number(tokens.expires_in ?? 0)
35212
+ });
35213
+ return identity;
35214
+ } catch (error) {
35215
+ session?.delete(_Sso.SESSION_KEY);
35216
+ throw error;
35217
+ }
35218
+ }
35219
+ async logout(requestOrSession, returnTo = "/") {
35220
+ const session = this.session(requestOrSession);
35221
+ const stored = session?.get(_Sso.SESSION_KEY);
35222
+ session?.destroy();
35223
+ const endpoint = (await this.discover()).end_session_endpoint;
35224
+ const target = this.postLogoutRedirectUri ?? _Sso.safeReturn(returnTo);
35225
+ if (!endpoint) return target;
35226
+ const params = new URLSearchParams({ post_logout_redirect_uri: target, client_id: this.clientId });
35227
+ if (stored?.id_token) params.set("id_token_hint", stored.id_token);
35228
+ return `${endpoint}?${params}`;
35229
+ }
35230
+ static async mountConfigured(router) {
35231
+ if (_Sso.mountedRouters.has(router) || !_Sso.configured()) return false;
35232
+ const owned = /* @__PURE__ */ new Set(["GET /auth/login", "GET /auth/callback", "POST /auth/logout"]);
35233
+ const collisions = router.getRoutes().map((route) => `${route.method} ${route.pattern}`).filter((route) => owned.has(route));
35234
+ if (collisions.length) throw new SsoError(`SSO route collision: ${collisions.join(", ")}`);
35235
+ const sso = await _Sso.fromIssuer();
35236
+ router.get("/auth/login", async (req2, res) => res.redirect(await sso.login(req2, req2.query?.return_to ?? "/")));
35237
+ router.get("/auth/callback", async (req2, res) => {
35238
+ try {
35239
+ return res.redirect((await sso.callback(req2)).return_to);
35240
+ } catch (error) {
35241
+ const message = error instanceof SsoError ? error.message : "OIDC callback failed";
35242
+ return res.error("SSO_CALLBACK_FAILED", message, 400);
35243
+ }
35244
+ });
35245
+ router.post("/auth/logout", async (req2, res) => res.redirect(await sso.logout(req2, req2.query?.return_to ?? "/")));
35246
+ _Sso.mountedRouters.add(router);
35247
+ return true;
35248
+ }
35249
+ };
35250
+ }
35251
+ });
35252
+
34569
35253
  // src/docsAutoDiscovery.ts
34570
35254
  var docsAutoDiscovery_exports = {};
34571
35255
  __export(docsAutoDiscovery_exports, {
@@ -34635,7 +35319,7 @@ var init_docsAutoDiscovery = __esm({
34635
35319
 
34636
35320
  // src/server.ts
34637
35321
  import { createServer as createServer2 } from "node:http";
34638
- import { randomBytes as randomBytes7 } from "node:crypto";
35322
+ import { randomBytes as randomBytes8 } from "node:crypto";
34639
35323
  import { resolve as resolve18, dirname as dirname13, join as join29, relative as relative8 } from "node:path";
34640
35324
  import { existsSync as existsSync24, readdirSync as readdirSync17, readFileSync as readFileSync23, statSync as statSync17 } from "node:fs";
34641
35325
  import { isatty } from "node:tty";
@@ -35275,7 +35959,7 @@ async function renderDispatchError(err, req2, res, templatesDir) {
35275
35959
  }
35276
35960
  }
35277
35961
  }
35278
- const requestId = Log.getRequestId() ?? randomBytes7(4).toString("hex");
35962
+ const requestId = Log.getRequestId() ?? randomBytes8(4).toString("hex");
35279
35963
  if (wantsJson(req2)) {
35280
35964
  const body = negotiatedErrorBody(500, "Internal Server Error", requestId);
35281
35965
  res.raw.writeHead(500, { "Content-Type": "application/json" });
@@ -35346,7 +36030,7 @@ function serveStaticAsset(ctx) {
35346
36030
  return false;
35347
36031
  }
35348
36032
  async function serveNotFound(ctx) {
35349
- const requestId = Log.getRequestId() ?? randomBytes7(4).toString("hex");
36033
+ const requestId = Log.getRequestId() ?? randomBytes8(4).toString("hex");
35350
36034
  if (wantsJson(ctx.req)) {
35351
36035
  const body = negotiatedErrorBody(404, "Not Found", requestId);
35352
36036
  ctx.res.raw.writeHead(404, httpReason(404), { "Content-Type": "application/json" });
@@ -35466,7 +36150,7 @@ async function dispatchInner(ctx, rawReq, rawRes, requestId) {
35466
36150
  }
35467
36151
  }
35468
36152
  async function runDispatch(ctx, rawReq, rawRes) {
35469
- const requestId = Log.sanitizeRequestId(rawReq.headers["x-request-id"]) ?? randomBytes7(4).toString("hex");
36153
+ const requestId = Log.sanitizeRequestId(rawReq.headers["x-request-id"]) ?? randomBytes8(4).toString("hex");
35470
36154
  if (!rawRes.headersSent) rawRes.setHeader("x-request-id", requestId);
35471
36155
  return Log.runWithRequestId(requestId, () => dispatchInner(ctx, rawReq, rawRes, requestId));
35472
36156
  }
@@ -35600,6 +36284,8 @@ ${reset2}
35600
36284
  console.log(`
35601
36285
  No routes directory found at ${routesDir}`);
35602
36286
  }
36287
+ const { Sso: Sso2 } = await Promise.resolve().then(() => (init_sso(), sso_exports));
36288
+ await Sso2.mountConfigured(router);
35603
36289
  if (attachCsrfFromEnv()) {
35604
36290
  console.log(`
35605
36291
  \x1B[36mCSRF\x1B[0m protection enabled (TINA4_CSRF)`);
@@ -36124,7 +36810,7 @@ var init_mqttMessage = __esm({
36124
36810
  // src/mqtt.ts
36125
36811
  import net2 from "node:net";
36126
36812
  import tls from "node:tls";
36127
- import { randomBytes as randomBytes8 } from "node:crypto";
36813
+ import { randomBytes as randomBytes9 } from "node:crypto";
36128
36814
  import { existsSync as existsSync25, readFileSync as readFileSync24 } from "node:fs";
36129
36815
  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;
36130
36816
  var init_mqtt = __esm({
@@ -36212,7 +36898,7 @@ var init_mqtt = __esm({
36212
36898
  this.caFile = options.caFile ?? (Env.str("TINA4_MQTT_CA_FILE") || null);
36213
36899
  this.tlsVerify = options.tlsVerify ?? Env.bool("TINA4_MQTT_TLS_VERIFY", true);
36214
36900
  let cid = options.clientId ?? (Env.str("TINA4_MQTT_CLIENT_ID") || null);
36215
- if (cid === null || cid === "") cid = "tina4-" + randomBytes8(8).toString("hex");
36901
+ if (cid === null || cid === "") cid = "tina4-" + randomBytes9(8).toString("hex");
36216
36902
  this.clientId = cid;
36217
36903
  this.keepalive = options.keepalive ?? Env.int("TINA4_MQTT_KEEPALIVE", DEFAULT_KEEPALIVE);
36218
36904
  this.cleanSession = options.cleanSession ?? true;
@@ -37142,7 +37828,7 @@ var init_service = __esm({
37142
37828
  import http from "node:http";
37143
37829
  import https from "node:https";
37144
37830
  import { URL as URL2 } from "node:url";
37145
- import { randomBytes as randomBytes9 } from "node:crypto";
37831
+ import { randomBytes as randomBytes10 } from "node:crypto";
37146
37832
  import { promises as fsp, createWriteStream } from "node:fs";
37147
37833
  import { basename as basename5 } from "node:path";
37148
37834
  import { pipeline } from "node:stream/promises";
@@ -37440,7 +38126,7 @@ var init_api = __esm({
37440
38126
  return { http_code: null, body: null, headers: {}, error: "upload requires filePath or fileBytes" };
37441
38127
  }
37442
38128
  const partContentType = guessContentType(uploadName);
37443
- const boundary = "----Tina4Boundary" + randomBytes9(16).toString("hex");
38129
+ const boundary = "----Tina4Boundary" + randomBytes10(16).toString("hex");
37444
38130
  const bodyBuffer = buildMultipartBody(boundary, fieldName, uploadName, content, partContentType, extraFields);
37445
38131
  const contentType = `multipart/form-data; boundary=${boundary}`;
37446
38132
  return this.execute("POST", this.buildUrl(path8), bodyBuffer, contentType, headers);
@@ -42272,10 +42958,13 @@ __export(index_exports, {
42272
42958
  RouteGroup: () => RouteGroup,
42273
42959
  RouteRef: () => RouteRef,
42274
42960
  Router: () => Router,
42961
+ SSO: () => Sso,
42275
42962
  SafeString: () => SafeString2,
42276
42963
  SecurityHeadersMiddleware: () => SecurityHeadersMiddleware,
42277
42964
  ServiceRunner: () => ServiceRunner,
42278
42965
  Session: () => Session,
42966
+ Sso: () => Sso,
42967
+ SsoError: () => SsoError,
42279
42968
  TAKEOVER_KILLED: () => TAKEOVER_KILLED,
42280
42969
  TAKEOVER_NOTHING: () => TAKEOVER_NOTHING,
42281
42970
  TAKEOVER_REFUSALS: () => TAKEOVER_REFUSALS,
@@ -42511,6 +43200,7 @@ var init_index = __esm({
42511
43200
  init_htmlElement();
42512
43201
  init_errorOverlay();
42513
43202
  init_ai();
43203
+ init_sso();
42514
43204
  init_aiClient();
42515
43205
  init_liteBackend();
42516
43206
  init_rabbitmqBackend();
@@ -42634,10 +43324,13 @@ export {
42634
43324
  RouteGroup,
42635
43325
  RouteRef,
42636
43326
  Router,
43327
+ Sso as SSO,
42637
43328
  SafeString2 as SafeString,
42638
43329
  SecurityHeadersMiddleware,
42639
43330
  ServiceRunner,
42640
43331
  Session,
43332
+ Sso,
43333
+ SsoError,
42641
43334
  TAKEOVER_KILLED,
42642
43335
  TAKEOVER_NOTHING,
42643
43336
  TAKEOVER_REFUSALS,