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.
- package/CLAUDE.md +16 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +747 -57
- package/packages/core/dist/index.js +750 -57
- package/packages/core/src/authGate.ts +6 -0
- package/packages/core/src/index.ts +2 -0
- package/packages/core/src/queue.ts +75 -18
- package/packages/core/src/queueBackends/liteBackend.ts +17 -1
- package/packages/core/src/queueBackends/mongoBackend.ts +80 -14
- package/packages/core/src/server.ts +5 -0
- package/packages/core/src/sso.ts +285 -0
- package/packages/orm/dist/index.js +755 -62
- package/packages/orm/src/adapters/postgres.ts +2 -0
- package/packages/orm/src/baseModel.ts +63 -10
- package/packages/orm/src/index.ts +2 -0
- package/packages/orm/src/migration.ts +14 -5
- package/packages/orm/src/point.ts +105 -0
- package/packages/orm/src/queryBuilder.ts +66 -5
- package/packages/orm/src/sqlTranslator.ts +63 -0
- package/packages/orm/src/types.ts +5 -1
- package/packages/swagger/dist/index.js +11 -1
- package/packages/swagger/src/generator.ts +11 -1
- package/types/core/src/index.d.ts +2 -0
- package/types/core/src/queue.d.ts +41 -4
- package/types/core/src/queueBackends/liteBackend.d.ts +7 -1
- package/types/core/src/queueBackends/mongoBackend.d.ts +7 -2
- package/types/core/src/sso.d.ts +55 -0
- package/types/orm/src/baseModel.d.ts +13 -5
- package/types/orm/src/index.d.ts +2 -0
- package/types/orm/src/point.d.ts +24 -0
- package/types/orm/src/queryBuilder.d.ts +10 -1
- package/types/orm/src/sqlTranslator.d.ts +13 -0
- 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
|
-
|
|
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
|
|
12379
|
+
batch INTEGER DEFAULT 1 NOT NULL,
|
|
12217
12380
|
executed_at VARCHAR(50) NOT NULL,
|
|
12218
|
-
passed INTEGER NOT NULL
|
|
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]?.
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
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
|
|
14638
|
-
* this table is busted too
|
|
14639
|
-
* never touches this table is left intact
|
|
14640
|
-
*
|
|
14641
|
-
*
|
|
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 (
|
|
27835
|
-
//
|
|
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
|
-
:
|
|
27840
|
-
await col.
|
|
27841
|
-
|
|
27842
|
-
|
|
27843
|
-
|
|
27844
|
-
|
|
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:
|
|
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
|
|
27892
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
|
|
28916
|
-
|
|
28917
|
-
|
|
28918
|
-
|
|
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
|
|
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
|
-
|
|
28953
|
-
|
|
28954
|
-
|
|
28955
|
-
|
|
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
|
-
|
|
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
|
|
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() ??
|
|
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() ??
|
|
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"]) ??
|
|
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
|
|
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-" +
|
|
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
|
|
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" +
|
|
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,
|