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
|
@@ -582,13 +582,172 @@ var init_databaseUrl = __esm({
|
|
|
582
582
|
}
|
|
583
583
|
});
|
|
584
584
|
|
|
585
|
+
// src/point.ts
|
|
586
|
+
function formatCoordinate(value) {
|
|
587
|
+
return Object.is(value, -0) ? "0" : Number(value.toPrecision(15)).toString();
|
|
588
|
+
}
|
|
589
|
+
var DEFAULT_SRID, SpatialNotSupportedError, Point;
|
|
590
|
+
var init_point = __esm({
|
|
591
|
+
"src/point.ts"() {
|
|
592
|
+
"use strict";
|
|
593
|
+
DEFAULT_SRID = 4326;
|
|
594
|
+
SpatialNotSupportedError = class extends Error {
|
|
595
|
+
constructor(message) {
|
|
596
|
+
super(message);
|
|
597
|
+
this.name = "SpatialNotSupportedError";
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
Point = class _Point {
|
|
601
|
+
lon;
|
|
602
|
+
lat;
|
|
603
|
+
srid;
|
|
604
|
+
constructor(lon, lat, srid = DEFAULT_SRID) {
|
|
605
|
+
if (typeof lon === "boolean" || typeof lat === "boolean" || typeof srid === "boolean") {
|
|
606
|
+
throw new TypeError("Point longitude, latitude and SRID must be numbers");
|
|
607
|
+
}
|
|
608
|
+
this.lon = Number(lon);
|
|
609
|
+
this.lat = Number(lat);
|
|
610
|
+
this.srid = Number(srid);
|
|
611
|
+
if (!Number.isFinite(this.lon) || !Number.isFinite(this.lat) || !Number.isInteger(this.srid)) {
|
|
612
|
+
throw new TypeError("Point longitude and latitude must be finite numbers and SRID must be an integer");
|
|
613
|
+
}
|
|
614
|
+
if (this.srid === DEFAULT_SRID) {
|
|
615
|
+
if (this.lon < -180 || this.lon > 180) throw new RangeError(`Point longitude ${this.lon} is outside -180..180; Tina4 uses longitude, latitude order`);
|
|
616
|
+
if (this.lat < -90 || this.lat > 90) throw new RangeError(`Point latitude ${this.lat} is outside -90..90; Tina4 uses longitude, latitude order`);
|
|
617
|
+
}
|
|
618
|
+
Object.freeze(this);
|
|
619
|
+
}
|
|
620
|
+
get wkt() {
|
|
621
|
+
return `POINT(${formatCoordinate(this.lon)} ${formatCoordinate(this.lat)})`;
|
|
622
|
+
}
|
|
623
|
+
get ewkt() {
|
|
624
|
+
return `SRID=${this.srid};${this.wkt}`;
|
|
625
|
+
}
|
|
626
|
+
get geojson() {
|
|
627
|
+
return { type: "Point", coordinates: [this.lon, this.lat] };
|
|
628
|
+
}
|
|
629
|
+
toJSON() {
|
|
630
|
+
return this.geojson;
|
|
631
|
+
}
|
|
632
|
+
toArray() {
|
|
633
|
+
return [this.lon, this.lat];
|
|
634
|
+
}
|
|
635
|
+
static parse(value, srid = DEFAULT_SRID) {
|
|
636
|
+
if (value instanceof _Point) return value;
|
|
637
|
+
if (Array.isArray(value)) {
|
|
638
|
+
if (value.length < 2) throw new TypeError("Point coordinate pair needs longitude and latitude");
|
|
639
|
+
return new _Point(value[0], value[1], srid);
|
|
640
|
+
}
|
|
641
|
+
if (value && typeof value === "object" && !(value instanceof Uint8Array)) {
|
|
642
|
+
return _Point.fromGeoJson(value, srid);
|
|
643
|
+
}
|
|
644
|
+
if (value instanceof Uint8Array) return _Point.fromWkb(value, srid);
|
|
645
|
+
if (typeof value === "string") {
|
|
646
|
+
const text = value.trim();
|
|
647
|
+
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);
|
|
648
|
+
if (match) return new _Point(match[2], match[3], match[1] ? Number(match[1]) : srid);
|
|
649
|
+
if (text.length >= 42 && text.length % 2 === 0 && /^[0-9a-f]+$/i.test(text)) {
|
|
650
|
+
return _Point.fromWkb(Uint8Array.from(Buffer.from(text, "hex")), srid);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
throw new TypeError("Point must be Point, [longitude, latitude], WKT/EWKT, GeoJSON or WKB/EWKB");
|
|
654
|
+
}
|
|
655
|
+
static geometryBinding(value, srid = DEFAULT_SRID) {
|
|
656
|
+
if (value instanceof _Point || Array.isArray(value)) return [_Point.parse(value, srid).ewkt, "ewkt"];
|
|
657
|
+
if (value && typeof value === "object") {
|
|
658
|
+
const candidate = value;
|
|
659
|
+
const geometry = String(candidate.type).toLowerCase() === "feature" ? candidate.geometry : candidate;
|
|
660
|
+
const allowed = /* @__PURE__ */ new Set(["point", "linestring", "polygon", "multipoint", "multilinestring", "multipolygon", "geometrycollection"]);
|
|
661
|
+
if (!geometry || !allowed.has(String(geometry.type).toLowerCase())) throw new TypeError("GeoJSON geometry has an unsupported type");
|
|
662
|
+
return [JSON.stringify(geometry), "geojson"];
|
|
663
|
+
}
|
|
664
|
+
if (typeof value === "string" && /^\s*(?:SRID\s*=\s*\d+\s*;\s*)?(?:POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON|GEOMETRYCOLLECTION)\b/i.test(value)) {
|
|
665
|
+
return [/^\s*SRID/i.test(value) ? value.trim() : `SRID=${srid};${value.trim()}`, "ewkt"];
|
|
666
|
+
}
|
|
667
|
+
throw new TypeError("Geometry must be Point, coordinate pair, WKT/EWKT or GeoJSON");
|
|
668
|
+
}
|
|
669
|
+
static fromGeoJson(data, srid) {
|
|
670
|
+
const geometry = String(data.type).toLowerCase() === "feature" ? data.geometry : data;
|
|
671
|
+
if (!geometry || String(geometry.type).toLowerCase() !== "point") throw new TypeError("Point GeoJSON type must be Point");
|
|
672
|
+
const coordinates = geometry.coordinates;
|
|
673
|
+
if (!Array.isArray(coordinates) || coordinates.length < 2) throw new TypeError("Point GeoJSON coordinates must be [longitude, latitude]");
|
|
674
|
+
return new _Point(coordinates[0], coordinates[1], srid);
|
|
675
|
+
}
|
|
676
|
+
static fromWkb(raw, srid) {
|
|
677
|
+
if (raw.byteLength < 21) throw new TypeError("Point WKB is too short");
|
|
678
|
+
const view = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
|
|
679
|
+
const little = raw[0] === 1;
|
|
680
|
+
const typeWord = view.getUint32(1, little);
|
|
681
|
+
let offset = 5;
|
|
682
|
+
if ((typeWord & 536870912) !== 0) {
|
|
683
|
+
srid = view.getUint32(5, little);
|
|
684
|
+
offset = 9;
|
|
685
|
+
}
|
|
686
|
+
const code = (typeWord & ~(536870912 | 1073741824 | 2147483648)) % 1e3;
|
|
687
|
+
if (code !== 1 || raw.byteLength < offset + 16) throw new TypeError("WKB geometry is not a Point");
|
|
688
|
+
return new _Point(view.getFloat64(offset, little), view.getFloat64(offset + 8, little), srid);
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
|
|
585
694
|
// src/sqlTranslator.ts
|
|
586
695
|
var SQLTranslator, QueryCache;
|
|
587
696
|
var init_sqlTranslator = __esm({
|
|
588
697
|
"src/sqlTranslator.ts"() {
|
|
589
698
|
"use strict";
|
|
590
699
|
init_databaseUrl();
|
|
700
|
+
init_point();
|
|
591
701
|
SQLTranslator = class _SQLTranslator {
|
|
702
|
+
static SPATIAL_ENGINES = /* @__PURE__ */ new Set(["postgres", "postgresql"]);
|
|
703
|
+
static SPATIAL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
704
|
+
static requireSpatial(engine, feature) {
|
|
705
|
+
const name = String(engine || "unknown").toLowerCase();
|
|
706
|
+
if (!_SQLTranslator.SPATIAL_ENGINES.has(name)) {
|
|
707
|
+
throw new SpatialNotSupportedError(
|
|
708
|
+
`${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.`
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
return name;
|
|
712
|
+
}
|
|
713
|
+
static spatialIdentifier(name, what = "column") {
|
|
714
|
+
if (!_SQLTranslator.SPATIAL_IDENTIFIER.test(name)) throw new TypeError(`Spatial ${what} is not a valid SQL identifier: ${name}`);
|
|
715
|
+
return name;
|
|
716
|
+
}
|
|
717
|
+
static pointColumnType(engine, srid = DEFAULT_SRID) {
|
|
718
|
+
_SQLTranslator.requireSpatial(engine, "PointField");
|
|
719
|
+
return `geography(Point,${srid})`;
|
|
720
|
+
}
|
|
721
|
+
static spatialIndex(engine, table2, column2) {
|
|
722
|
+
_SQLTranslator.requireSpatial(engine, "spatial index creation");
|
|
723
|
+
table2 = _SQLTranslator.spatialIdentifier(table2, "table");
|
|
724
|
+
column2 = _SQLTranslator.spatialIdentifier(column2);
|
|
725
|
+
return `CREATE INDEX IF NOT EXISTS ${table2.replaceAll(".", "_")}_${column2}_gist ON ${table2} USING GIST (${column2})`;
|
|
726
|
+
}
|
|
727
|
+
static pointLiteral(engine, srid = DEFAULT_SRID) {
|
|
728
|
+
_SQLTranslator.requireSpatial(engine, "spatial predicates");
|
|
729
|
+
return `ST_SetSRID(ST_MakePoint(?, ?), ${srid})::geography`;
|
|
730
|
+
}
|
|
731
|
+
static withinDistance(engine, column2, srid = DEFAULT_SRID) {
|
|
732
|
+
return `ST_DWithin(${_SQLTranslator.spatialIdentifier(column2)}, ${_SQLTranslator.pointLiteral(engine, srid)}, ?)`;
|
|
733
|
+
}
|
|
734
|
+
static distance(engine, column2, srid = DEFAULT_SRID) {
|
|
735
|
+
return `ST_Distance(${_SQLTranslator.spatialIdentifier(column2)}, ${_SQLTranslator.pointLiteral(engine, srid)})`;
|
|
736
|
+
}
|
|
737
|
+
static distanceAs(engine, column2, alias, srid = DEFAULT_SRID) {
|
|
738
|
+
return `${_SQLTranslator.distance(engine, column2, srid)} AS ${_SQLTranslator.spatialIdentifier(alias, "result alias")}`;
|
|
739
|
+
}
|
|
740
|
+
static geometryLiteral(engine, form, srid = DEFAULT_SRID) {
|
|
741
|
+
_SQLTranslator.requireSpatial(engine, "spatial predicates");
|
|
742
|
+
return form === "ewkt" ? "ST_GeogFromText(?)" : `ST_SetSRID(ST_GeomFromGeoJSON(?), ${srid})::geography`;
|
|
743
|
+
}
|
|
744
|
+
static intersects(engine, column2, form = "ewkt", srid = DEFAULT_SRID) {
|
|
745
|
+
return `ST_Intersects(${_SQLTranslator.spatialIdentifier(column2)}, ${_SQLTranslator.geometryLiteral(engine, form, srid)})`;
|
|
746
|
+
}
|
|
747
|
+
static bbox(engine, column2, srid = DEFAULT_SRID) {
|
|
748
|
+
_SQLTranslator.requireSpatial(engine, "bbox");
|
|
749
|
+
return `ST_Intersects(${_SQLTranslator.spatialIdentifier(column2)}, ST_MakeEnvelope(?, ?, ?, ?, ${srid})::geography)`;
|
|
750
|
+
}
|
|
592
751
|
/**
|
|
593
752
|
* Convert LIMIT/OFFSET to Firebird ROWS...TO syntax.
|
|
594
753
|
*
|
|
@@ -8773,6 +8932,12 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
|
|
|
8773
8932
|
}
|
|
8774
8933
|
}
|
|
8775
8934
|
if (!resolvedToken) {
|
|
8935
|
+
const sso = req2.session?.get?.("_tina4_sso");
|
|
8936
|
+
const identity = sso?.identity;
|
|
8937
|
+
if (identity?.issuer && identity?.subject) {
|
|
8938
|
+
req2.user = identity;
|
|
8939
|
+
return false;
|
|
8940
|
+
}
|
|
8776
8941
|
const sessionToken = req2.session?.get?.("token");
|
|
8777
8942
|
if (sessionToken && validToken(sessionToken)) {
|
|
8778
8943
|
resolvedToken = sessionToken;
|
|
@@ -17084,17 +17249,67 @@ var init_mongoBackend = __esm({
|
|
|
17084
17249
|
process.stdout.write("__OK__");
|
|
17085
17250
|
}
|
|
17086
17251
|
else if (operation === "retry") {
|
|
17087
|
-
// Explicit manual re-queue (
|
|
17088
|
-
//
|
|
17252
|
+
// Explicit manual re-queue. Serves BOTH Queue.retry(id) (revive
|
|
17253
|
+
// a dead-letter job) AND job.retry() (manual re-queue of a live
|
|
17254
|
+
// reserved/pending job) so the Mongo backend matches
|
|
17255
|
+
// LiteBackend's dual behaviour.
|
|
17256
|
+
//
|
|
17257
|
+
// 1) DL revival (Queue.retry(id) after fail exhausted retries).
|
|
17258
|
+
// Pre-3.13.105 this branch was BROKEN: the search filter was
|
|
17259
|
+
// { queue: queueName, id, status: "failed" } -- three separate
|
|
17260
|
+
// reasons it could never match. dead_letter() inserts under
|
|
17261
|
+
// queueName + ".dead_letter" (not queueName), carries
|
|
17262
|
+
// status "dead" (not "failed"), and the original under
|
|
17263
|
+
// queueName was already acked to "completed" by the time the
|
|
17264
|
+
// DL was written. Now we look up in the DL namespace by id,
|
|
17265
|
+
// delete the DL doc first (so an interrupted retry never
|
|
17266
|
+
// leaves both a DL and a fresh pending doc), and upsert the
|
|
17267
|
+
// original back to pending -- re-hydrating if the original
|
|
17268
|
+
// was purged (housekeeping) so a retry always works.
|
|
17269
|
+
// 2) Live-doc manual re-queue (job.retry() on a job the caller
|
|
17270
|
+
// just popped and wants back in pending). The live-doc path
|
|
17271
|
+
// is preserved from before 3.13.105.
|
|
17272
|
+
//
|
|
17273
|
+
// Returns __OK__ when either path acted; __NOT_FOUND__ when
|
|
17274
|
+
// neither the DL nor the live doc existed, so Queue.retry(id)
|
|
17275
|
+
// can now report the pre-3.13.105 blanket-true as false for
|
|
17276
|
+
// unknown ids. data = JSON { id, delaySeconds }.
|
|
17089
17277
|
const info = JSON.parse(data);
|
|
17278
|
+
const dlTopic = queueName + ".dead_letter";
|
|
17279
|
+
const now = new Date().toISOString();
|
|
17090
17280
|
const avail = info.delaySeconds > 0
|
|
17091
17281
|
? new Date(Date.now() + info.delaySeconds * 1000).toISOString()
|
|
17092
|
-
:
|
|
17093
|
-
await col.
|
|
17094
|
-
|
|
17095
|
-
|
|
17096
|
-
|
|
17097
|
-
|
|
17282
|
+
: now;
|
|
17283
|
+
const dlDoc = await col.findOne({ queue: dlTopic, id: info.id });
|
|
17284
|
+
if (dlDoc !== null) {
|
|
17285
|
+
await col.deleteOne({ _id: dlDoc._id });
|
|
17286
|
+
const payload = dlDoc.payload ?? {};
|
|
17287
|
+
const priority = dlDoc.priority ?? 0;
|
|
17288
|
+
await col.updateOne(
|
|
17289
|
+
{ queue: queueName, id: info.id },
|
|
17290
|
+
{
|
|
17291
|
+
$set: {
|
|
17292
|
+
status: "pending",
|
|
17293
|
+
availableAt: avail,
|
|
17294
|
+
reservedAt: null,
|
|
17295
|
+
error: null,
|
|
17296
|
+
payload,
|
|
17297
|
+
priority,
|
|
17298
|
+
id: info.id,
|
|
17299
|
+
createdAt: dlDoc.createdAt ?? now,
|
|
17300
|
+
},
|
|
17301
|
+
$inc: { attempts: 1 },
|
|
17302
|
+
},
|
|
17303
|
+
{ upsert: true },
|
|
17304
|
+
);
|
|
17305
|
+
process.stdout.write("__OK__");
|
|
17306
|
+
} else {
|
|
17307
|
+
const result = await col.updateOne(
|
|
17308
|
+
{ queue: queueName, id: info.id },
|
|
17309
|
+
{ $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
|
|
17310
|
+
);
|
|
17311
|
+
process.stdout.write(result.matchedCount > 0 ? "__OK__" : "__NOT_FOUND__");
|
|
17312
|
+
}
|
|
17098
17313
|
}
|
|
17099
17314
|
else if (operation === "deadLetters") {
|
|
17100
17315
|
const docs = await col.find({ queue: queueName + ".dead_letter" }).toArray();
|
|
@@ -17139,10 +17354,20 @@ var init_mongoBackend = __esm({
|
|
|
17139
17354
|
process.stdout.write(String(revived));
|
|
17140
17355
|
}
|
|
17141
17356
|
else if (operation === "purge") {
|
|
17142
|
-
// Delete docs by status (default:
|
|
17357
|
+
// Delete docs by status (default: every doc for the topic).
|
|
17358
|
+
// Pre-3.13.105 this filtered by { queue: queueName, status } for
|
|
17359
|
+
// EVERY status -- correct for pending/reserved/completed, wrong
|
|
17360
|
+
// for the dead-letter states (dead/failed/dead_letter) which
|
|
17361
|
+
// live under queueName + ".dead_letter" and carry status "dead".
|
|
17362
|
+
// A purge("dead") therefore deleted nothing and returned 0.
|
|
17363
|
+
// data = JSON { status }.
|
|
17143
17364
|
const info = data ? JSON.parse(data) : {};
|
|
17144
|
-
const
|
|
17145
|
-
|
|
17365
|
+
const isDead = info.status && ["dead", "failed", "dead_letter"].includes(info.status);
|
|
17366
|
+
const filter = isDead
|
|
17367
|
+
? { queue: queueName + ".dead_letter" }
|
|
17368
|
+
: (info.status
|
|
17369
|
+
? { queue: queueName, status: info.status }
|
|
17370
|
+
: { queue: queueName });
|
|
17146
17371
|
const res = await col.deleteMany(filter);
|
|
17147
17372
|
process.stdout.write(String(res.deletedCount || 0));
|
|
17148
17373
|
}
|
|
@@ -17239,9 +17464,15 @@ var init_mongoBackend = __esm({
|
|
|
17239
17464
|
fail(queue, id, error, maxRetries, retryBackoff = 0) {
|
|
17240
17465
|
this.execSync("fail", queue, JSON.stringify({ id, error, maxRetries, retryBackoff }));
|
|
17241
17466
|
}
|
|
17242
|
-
/**
|
|
17467
|
+
/**
|
|
17468
|
+
* Revive a specific dead-letter job by id. Returns true if the DL was found
|
|
17469
|
+
* and revived, false otherwise (parity with LiteBackend.retry(queue, id)
|
|
17470
|
+
* and Python's mongo_backend.retry_job()). Pre-3.13.105 this returned void
|
|
17471
|
+
* and Queue.retry(id) reported success for every call, even for unknown ids.
|
|
17472
|
+
*/
|
|
17243
17473
|
retry(queue, id, delaySeconds = 0) {
|
|
17244
|
-
this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
|
|
17474
|
+
const out = this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
|
|
17475
|
+
return out.includes("__OK__");
|
|
17245
17476
|
}
|
|
17246
17477
|
/** Jobs that exceeded max retries (the `<queue>.dead_letter` collection topic). */
|
|
17247
17478
|
deadLetters(queue, maxRetries) {
|
|
@@ -17910,10 +18141,20 @@ var init_liteBackend = __esm({
|
|
|
17910
18141
|
* Explicit re-queue requested by the caller (job.retry()).
|
|
17911
18142
|
*
|
|
17912
18143
|
* Always re-enqueues regardless of the retry limit — manual override,
|
|
17913
|
-
* distinct from the automatic failJob() path.
|
|
18144
|
+
* distinct from the automatic failJob() path. Cleans up BOTH the
|
|
18145
|
+
* reservation record AND any dead-letter file for this id, so a caller
|
|
18146
|
+
* that iterates deadLetters() and calls .retry() on each doesn't leave
|
|
18147
|
+
* the failed/ directory carrying duplicates (PY-12-05, 3.13.105).
|
|
18148
|
+
* Aligns with retry(queue, jobId) which had always unlinked the
|
|
18149
|
+
* dead-letter file -- two spellings of the same intent that previously
|
|
18150
|
+
* diverged.
|
|
17914
18151
|
*/
|
|
17915
18152
|
retryJob(queue, job, delaySeconds) {
|
|
17916
18153
|
this.clearReservation(queue, job.id);
|
|
18154
|
+
try {
|
|
18155
|
+
unlinkSync6(join17(this.ensureFailedDir(queue), `${job.id}.queue-data`));
|
|
18156
|
+
} catch {
|
|
18157
|
+
}
|
|
17917
18158
|
job.attempts = (job.attempts || 0) + 1;
|
|
17918
18159
|
job.error = void 0;
|
|
17919
18160
|
this.requeue(queue, job, delaySeconds ?? 0, void 0);
|
|
@@ -18112,7 +18353,19 @@ var init_queue = __esm({
|
|
|
18112
18353
|
}
|
|
18113
18354
|
}
|
|
18114
18355
|
/**
|
|
18115
|
-
* Count jobs
|
|
18356
|
+
* Count jobs by status. Defaults to "pending".
|
|
18357
|
+
*
|
|
18358
|
+
* ``"pending"`` counts jobs waiting to be popped -- INCLUDES retryable-
|
|
18359
|
+
* but-attempted ones, because they live in the pending queue under the
|
|
18360
|
+
* auto-retry lifecycle (see failed()).
|
|
18361
|
+
* ``"reserved"`` counts jobs a consumer has popped but not yet
|
|
18362
|
+
* completed/failed (in-flight against the visibility timeout).
|
|
18363
|
+
* ``"completed"`` counts jobs the consumer has finished successfully.
|
|
18364
|
+
* ``"failed"``, ``"dead"``, ``"dead_letter"`` are ALIASES that all count
|
|
18365
|
+
* the dead-letter store -- jobs whose attempts >= maxRetries and that
|
|
18366
|
+
* have given up. Use deadLetters() to list them. Retryable-but-attempted
|
|
18367
|
+
* jobs are NOT counted by size("failed"); use failed() to list them or
|
|
18368
|
+
* size("pending") to include them in a total.
|
|
18116
18369
|
*/
|
|
18117
18370
|
size(status2 = "pending") {
|
|
18118
18371
|
const q = this.topic;
|
|
@@ -18162,13 +18415,17 @@ var init_queue = __esm({
|
|
|
18162
18415
|
/**
|
|
18163
18416
|
* Get jobs that failed at least once but are still being retried
|
|
18164
18417
|
* (0 < attempts < maxRetries). These live in the pending queue under the
|
|
18165
|
-
* auto-retry lifecycle
|
|
18418
|
+
* auto-retry lifecycle (fail() re-queues them with an incremented attempts
|
|
18419
|
+
* count and a retryBackoff delay) so pop() picks them up again. They are
|
|
18420
|
+
* NOT counted by size("failed") -- that alias counts the dead-letter store,
|
|
18421
|
+
* matching deadLetters(). To include retryable-failed jobs in a total, use
|
|
18422
|
+
* size("pending"). Terminal failures are returned by deadLetters().
|
|
18166
18423
|
*/
|
|
18167
18424
|
failed() {
|
|
18168
|
-
|
|
18169
|
-
|
|
18170
|
-
|
|
18171
|
-
|
|
18425
|
+
const raw = this.externalBackend?.failed ? this.externalBackend.failed(this.topic, this._maxRetries) : this.liteBackend.failed(this.topic, this._maxRetries);
|
|
18426
|
+
return raw.map(
|
|
18427
|
+
(data) => createJob({ ...data, topic: data.topic ?? this.topic }, this)
|
|
18428
|
+
);
|
|
18172
18429
|
}
|
|
18173
18430
|
/**
|
|
18174
18431
|
* Retry all dead letter jobs for this queue's topic.
|
|
@@ -18180,8 +18437,8 @@ var init_queue = __esm({
|
|
|
18180
18437
|
retry(jobId, delaySeconds) {
|
|
18181
18438
|
if (jobId) {
|
|
18182
18439
|
if (this.externalBackend?.retry) {
|
|
18183
|
-
this.externalBackend.retry(this.topic, jobId, delaySeconds);
|
|
18184
|
-
return true;
|
|
18440
|
+
const result = this.externalBackend.retry(this.topic, jobId, delaySeconds);
|
|
18441
|
+
return result === void 0 ? true : Boolean(result);
|
|
18185
18442
|
}
|
|
18186
18443
|
return this.liteBackend.retry(this.topic, jobId, delaySeconds);
|
|
18187
18444
|
}
|
|
@@ -18190,8 +18447,8 @@ var init_queue = __esm({
|
|
|
18190
18447
|
let retried = false;
|
|
18191
18448
|
for (const job of deadJobs) {
|
|
18192
18449
|
if (this.externalBackend?.retry) {
|
|
18193
|
-
this.externalBackend.retry(this.topic, job.id, delaySeconds);
|
|
18194
|
-
retried = true;
|
|
18450
|
+
const result = this.externalBackend.retry(this.topic, job.id, delaySeconds);
|
|
18451
|
+
if (result === void 0 || Boolean(result)) retried = true;
|
|
18195
18452
|
} else if (this.liteBackend.retry(this.topic, job.id, delaySeconds)) {
|
|
18196
18453
|
retried = true;
|
|
18197
18454
|
}
|
|
@@ -18199,13 +18456,28 @@ var init_queue = __esm({
|
|
|
18199
18456
|
return retried;
|
|
18200
18457
|
}
|
|
18201
18458
|
/**
|
|
18202
|
-
* Get
|
|
18459
|
+
* Get jobs that exceeded max_retries -- terminal failures.
|
|
18460
|
+
*
|
|
18461
|
+
* Same set counted by size("failed") / size("dead") / size("dead_letter")
|
|
18462
|
+
* (three aliases for the dead-letter store). To LIST retryable-but-
|
|
18463
|
+
* attempted jobs (attempts > 0 AND attempts < maxRetries) that are still
|
|
18464
|
+
* being auto-retried, use failed() -- those live in the pending queue and
|
|
18465
|
+
* are NOT dead letters.
|
|
18466
|
+
*
|
|
18467
|
+
* Returns Job objects with the failure reason on ``.error`` (not raw dicts)
|
|
18468
|
+
* so callers can iterate uniformly with the rest of the queue API and, in
|
|
18469
|
+
* particular, call ``.retry()`` on each to manually revive it:
|
|
18470
|
+
*
|
|
18471
|
+
* for (const job of queue.deadLetters()) {
|
|
18472
|
+
* Log.warn(`revived ${job.id}: ${job.error}`);
|
|
18473
|
+
* job.retry();
|
|
18474
|
+
* }
|
|
18203
18475
|
*/
|
|
18204
18476
|
deadLetters(maxRetries) {
|
|
18205
|
-
|
|
18206
|
-
|
|
18207
|
-
|
|
18208
|
-
|
|
18477
|
+
const raw = this.externalBackend?.deadLetters ? this.externalBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries) : this.liteBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
|
|
18478
|
+
return raw.map(
|
|
18479
|
+
(data) => createJob({ ...data, topic: data.topic ?? this.topic }, this)
|
|
18480
|
+
);
|
|
18209
18481
|
}
|
|
18210
18482
|
/**
|
|
18211
18483
|
* Delete messages by status (e.g. "completed", "failed", "dead").
|
|
@@ -23324,6 +23596,14 @@ function resolveSecuritySchemes() {
|
|
|
23324
23596
|
const apiKeyIn = ["header", "query", "cookie"].includes(rawIn) ? rawIn : "header";
|
|
23325
23597
|
schemes.apiKeyAuth = { type: "apiKey", name: apiKeyName, in: apiKeyIn };
|
|
23326
23598
|
}
|
|
23599
|
+
const ssoIssuer = (process.env.TINA4_SSO_ISSUER ?? "").replace(/\/$/, "");
|
|
23600
|
+
if (ssoIssuer) {
|
|
23601
|
+
schemes.oidc = {
|
|
23602
|
+
type: "openIdConnect",
|
|
23603
|
+
openIdConnectUrl: `${ssoIssuer}/.well-known/openid-configuration`
|
|
23604
|
+
};
|
|
23605
|
+
schemes.ssoSession = { type: "apiKey", in: "cookie", name: "tina4_session" };
|
|
23606
|
+
}
|
|
23327
23607
|
for (const [name, def] of Object.entries(registeredSchemes)) {
|
|
23328
23608
|
schemes[name] = def;
|
|
23329
23609
|
}
|
|
@@ -23505,7 +23785,9 @@ function generate(routes, models = []) {
|
|
|
23505
23785
|
if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
|
|
23506
23786
|
}
|
|
23507
23787
|
} else if (routeRequiresAuth(route, method)) {
|
|
23508
|
-
|
|
23788
|
+
const requirements = [{ [defaultScheme]: [] }];
|
|
23789
|
+
if (defaultScheme === "bearerAuth" && schemes.ssoSession) requirements.push({ ssoSession: [] });
|
|
23790
|
+
operation.security = sanitizeSecurity(requirements, schemes);
|
|
23509
23791
|
const responses = operation.responses;
|
|
23510
23792
|
if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
|
|
23511
23793
|
}
|
|
@@ -23819,6 +24101,298 @@ var init_src = __esm({
|
|
|
23819
24101
|
}
|
|
23820
24102
|
});
|
|
23821
24103
|
|
|
24104
|
+
// ../core/src/sso.ts
|
|
24105
|
+
var sso_exports = {};
|
|
24106
|
+
__export(sso_exports, {
|
|
24107
|
+
SSO: () => Sso,
|
|
24108
|
+
Sso: () => Sso,
|
|
24109
|
+
SsoError: () => SsoError
|
|
24110
|
+
});
|
|
24111
|
+
import { createHash as createHash9, randomBytes as randomBytes5, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
|
|
24112
|
+
var SsoError, Sso;
|
|
24113
|
+
var init_sso = __esm({
|
|
24114
|
+
"../core/src/sso.ts"() {
|
|
24115
|
+
"use strict";
|
|
24116
|
+
SsoError = class extends Error {
|
|
24117
|
+
};
|
|
24118
|
+
Sso = class _Sso {
|
|
24119
|
+
static PENDING_KEY = "_tina4_sso_pending";
|
|
24120
|
+
static SESSION_KEY = "_tina4_sso";
|
|
24121
|
+
issuer;
|
|
24122
|
+
clientId;
|
|
24123
|
+
clientSecret;
|
|
24124
|
+
redirectUri;
|
|
24125
|
+
scopes;
|
|
24126
|
+
verify;
|
|
24127
|
+
postLogoutRedirectUri;
|
|
24128
|
+
claimMap;
|
|
24129
|
+
timeout;
|
|
24130
|
+
metadata = {};
|
|
24131
|
+
static mountedRouters = /* @__PURE__ */ new WeakSet();
|
|
24132
|
+
constructor(options = {}) {
|
|
24133
|
+
this.issuer = (options.issuer ?? process.env.TINA4_SSO_ISSUER ?? "").replace(/\/$/, "");
|
|
24134
|
+
this.clientId = options.clientId ?? process.env.TINA4_SSO_CLIENT_ID ?? "";
|
|
24135
|
+
this.clientSecret = options.clientSecret ?? process.env.TINA4_SSO_CLIENT_SECRET;
|
|
24136
|
+
this.redirectUri = options.redirectUri ?? process.env.TINA4_SSO_REDIRECT_URI ?? "";
|
|
24137
|
+
this.scopes = options.scopes ?? this.jsonEnv("TINA4_SSO_SCOPES", ["openid", "profile", "email"]);
|
|
24138
|
+
this.verify = options.verify ?? process.env.TINA4_SSO_VERIFY ?? "introspection";
|
|
24139
|
+
this.postLogoutRedirectUri = options.postLogoutRedirectUri ?? process.env.TINA4_SSO_POST_LOGOUT_REDIRECT_URI;
|
|
24140
|
+
this.claimMap = options.claimMap ?? this.jsonEnv("TINA4_SSO_CLAIM_MAP", {});
|
|
24141
|
+
this.timeout = options.timeout ?? 1e4;
|
|
24142
|
+
this.validateConfig();
|
|
24143
|
+
}
|
|
24144
|
+
static async fromIssuer(options = {}) {
|
|
24145
|
+
const value = new _Sso(options);
|
|
24146
|
+
await value.discover();
|
|
24147
|
+
return value;
|
|
24148
|
+
}
|
|
24149
|
+
static configured() {
|
|
24150
|
+
return ["TINA4_SSO_ISSUER", "TINA4_SSO_CLIENT_ID", "TINA4_SSO_REDIRECT_URI"].every((key) => Boolean(process.env[key]));
|
|
24151
|
+
}
|
|
24152
|
+
jsonEnv(name, fallback) {
|
|
24153
|
+
const raw = process.env[name];
|
|
24154
|
+
if (!raw) return fallback;
|
|
24155
|
+
try {
|
|
24156
|
+
return JSON.parse(raw);
|
|
24157
|
+
} catch {
|
|
24158
|
+
throw new SsoError(`${name} must be valid JSON`);
|
|
24159
|
+
}
|
|
24160
|
+
}
|
|
24161
|
+
static secureUrl(value, name) {
|
|
24162
|
+
let url;
|
|
24163
|
+
try {
|
|
24164
|
+
url = new URL(value);
|
|
24165
|
+
} catch {
|
|
24166
|
+
throw new SsoError(`${name} must be an absolute URL`);
|
|
24167
|
+
}
|
|
24168
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
24169
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
24170
|
+
throw new SsoError(`${name} must use HTTPS except on loopback`);
|
|
24171
|
+
}
|
|
24172
|
+
}
|
|
24173
|
+
validateConfig() {
|
|
24174
|
+
if (!this.issuer || !this.clientId || !this.redirectUri) {
|
|
24175
|
+
throw new SsoError("TINA4_SSO_ISSUER, TINA4_SSO_CLIENT_ID and TINA4_SSO_REDIRECT_URI are required");
|
|
24176
|
+
}
|
|
24177
|
+
_Sso.secureUrl(this.issuer, "issuer");
|
|
24178
|
+
_Sso.secureUrl(this.redirectUri, "redirect URI");
|
|
24179
|
+
if (!["introspection", "jwks"].includes(this.verify)) throw new SsoError("TINA4_SSO_VERIFY must be introspection or jwks");
|
|
24180
|
+
if (this.verify === "jwks") throw new SsoError("jwks verification requires an installed cryptography capability");
|
|
24181
|
+
if (this.verify === "introspection" && !this.clientSecret) throw new SsoError("introspection verification requires TINA4_SSO_CLIENT_SECRET");
|
|
24182
|
+
if (!Array.isArray(this.scopes) || !this.scopes.includes("openid")) throw new SsoError("TINA4_SSO_SCOPES must be a list containing openid");
|
|
24183
|
+
}
|
|
24184
|
+
async requestJson(url, form, bearer, basic = false) {
|
|
24185
|
+
const headers = { Accept: "application/json" };
|
|
24186
|
+
let body;
|
|
24187
|
+
if (form) {
|
|
24188
|
+
const parameters = new URLSearchParams();
|
|
24189
|
+
for (const [key, value] of Object.entries(form)) parameters.set(key, String(value));
|
|
24190
|
+
body = parameters.toString();
|
|
24191
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
24192
|
+
}
|
|
24193
|
+
if (bearer) headers.Authorization = `Bearer ${bearer}`;
|
|
24194
|
+
if (basic) headers.Authorization = `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString("base64")}`;
|
|
24195
|
+
const controller = new AbortController();
|
|
24196
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
24197
|
+
try {
|
|
24198
|
+
const response = await fetch(url, { method: form ? "POST" : "GET", headers, body, signal: controller.signal });
|
|
24199
|
+
if (!response.ok) throw new SsoError("OIDC provider request failed");
|
|
24200
|
+
const result = await response.json();
|
|
24201
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) throw new SsoError("OIDC provider returned a non-object response");
|
|
24202
|
+
return result;
|
|
24203
|
+
} catch (error) {
|
|
24204
|
+
if (error instanceof SsoError) throw error;
|
|
24205
|
+
throw new SsoError("OIDC provider request failed");
|
|
24206
|
+
} finally {
|
|
24207
|
+
clearTimeout(timer);
|
|
24208
|
+
}
|
|
24209
|
+
}
|
|
24210
|
+
async discover(force = false) {
|
|
24211
|
+
if (Object.keys(this.metadata).length && !force) return { ...this.metadata };
|
|
24212
|
+
const result = await this.requestJson(`${this.issuer}/.well-known/openid-configuration`);
|
|
24213
|
+
if (result.issuer !== this.issuer) throw new SsoError("OIDC discovery issuer does not exactly match configuration");
|
|
24214
|
+
const required = ["authorization_endpoint", "token_endpoint"];
|
|
24215
|
+
if (this.verify === "introspection") required.push("introspection_endpoint");
|
|
24216
|
+
for (const key of required) {
|
|
24217
|
+
if (!result[key]) throw new SsoError(`OIDC discovery is missing ${key}`);
|
|
24218
|
+
_Sso.secureUrl(result[key], key);
|
|
24219
|
+
}
|
|
24220
|
+
this.metadata = result;
|
|
24221
|
+
return { ...result };
|
|
24222
|
+
}
|
|
24223
|
+
static safeReturn(value) {
|
|
24224
|
+
if (!value || !value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return "/";
|
|
24225
|
+
return [...value].some((char) => char.charCodeAt(0) < 32) ? "/" : value;
|
|
24226
|
+
}
|
|
24227
|
+
session(value) {
|
|
24228
|
+
return value?.session ?? value;
|
|
24229
|
+
}
|
|
24230
|
+
async login(requestOrSession, returnTo = "/") {
|
|
24231
|
+
const session = this.session(requestOrSession);
|
|
24232
|
+
if (!session) throw new SsoError("SSO login requires a Tina4 Session");
|
|
24233
|
+
const state = randomBytes5(32).toString("base64url");
|
|
24234
|
+
const nonce = randomBytes5(32).toString("base64url");
|
|
24235
|
+
const verifier = randomBytes5(64).toString("base64url");
|
|
24236
|
+
const challenge = createHash9("sha256").update(verifier).digest("base64url");
|
|
24237
|
+
session.set(_Sso.PENDING_KEY, { state, nonce, verifier, return_to: _Sso.safeReturn(returnTo), created_at: Math.floor(Date.now() / 1e3) });
|
|
24238
|
+
const metadata = await this.discover();
|
|
24239
|
+
const query = new URLSearchParams({
|
|
24240
|
+
client_id: this.clientId,
|
|
24241
|
+
redirect_uri: this.redirectUri,
|
|
24242
|
+
response_type: "code",
|
|
24243
|
+
scope: this.scopes.join(" "),
|
|
24244
|
+
state,
|
|
24245
|
+
nonce,
|
|
24246
|
+
code_challenge: challenge,
|
|
24247
|
+
code_challenge_method: "S256"
|
|
24248
|
+
});
|
|
24249
|
+
return `${metadata.authorization_endpoint}?${query}`;
|
|
24250
|
+
}
|
|
24251
|
+
static equal(left, right) {
|
|
24252
|
+
const a = Buffer.from(String(left ?? ""));
|
|
24253
|
+
const b = Buffer.from(String(right ?? ""));
|
|
24254
|
+
return a.length === b.length && timingSafeEqual3(a, b);
|
|
24255
|
+
}
|
|
24256
|
+
static jwtPayload(token) {
|
|
24257
|
+
try {
|
|
24258
|
+
return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString());
|
|
24259
|
+
} catch {
|
|
24260
|
+
throw new SsoError("provider returned an invalid ID token");
|
|
24261
|
+
}
|
|
24262
|
+
}
|
|
24263
|
+
async introspect(accessToken) {
|
|
24264
|
+
const metadata = await this.discover();
|
|
24265
|
+
const result = await this.requestJson(metadata.introspection_endpoint, { token: accessToken, token_type_hint: "access_token" }, void 0, true);
|
|
24266
|
+
if (result.active !== true || result.iss !== this.issuer) throw new SsoError("OIDC access token is inactive or has the wrong issuer");
|
|
24267
|
+
const audience = result.aud ?? result.client_id;
|
|
24268
|
+
const valid = (Array.isArray(audience) ? audience.includes(this.clientId) : audience === this.clientId) || result.client_id === this.clientId;
|
|
24269
|
+
if (!valid) throw new SsoError("OIDC token audience mismatch");
|
|
24270
|
+
return result;
|
|
24271
|
+
}
|
|
24272
|
+
claim(claims, configured, fallback) {
|
|
24273
|
+
let value = claims;
|
|
24274
|
+
for (const part of (configured ?? fallback).split(".")) value = value && typeof value === "object" ? value[part] : void 0;
|
|
24275
|
+
return value;
|
|
24276
|
+
}
|
|
24277
|
+
normalize(claims) {
|
|
24278
|
+
const subject = this.claim(claims, this.claimMap.subject, "sub");
|
|
24279
|
+
const issuer = this.claim(claims, this.claimMap.issuer, "iss") ?? this.issuer;
|
|
24280
|
+
if (!subject || issuer !== this.issuer) throw new SsoError("OIDC identity is missing a valid issuer or subject");
|
|
24281
|
+
const roles = [...this.claim(claims, this.claimMap.roles, "realm_access.roles") ?? [], ...claims.resource_access?.[this.clientId]?.roles ?? []];
|
|
24282
|
+
const groups = this.claim(claims, this.claimMap.groups, "groups") ?? [];
|
|
24283
|
+
return {
|
|
24284
|
+
issuer,
|
|
24285
|
+
subject,
|
|
24286
|
+
username: this.claim(claims, this.claimMap.username, "preferred_username") ?? null,
|
|
24287
|
+
email: this.claim(claims, this.claimMap.email, "email") ?? null,
|
|
24288
|
+
name: this.claim(claims, this.claimMap.name, "name") ?? null,
|
|
24289
|
+
roles: [...new Set(roles.map(String))].sort(),
|
|
24290
|
+
groups: [...new Set(groups.map(String))].sort()
|
|
24291
|
+
};
|
|
24292
|
+
}
|
|
24293
|
+
async callback(requestOrSession, query) {
|
|
24294
|
+
const session = this.session(requestOrSession);
|
|
24295
|
+
const values = query ?? requestOrSession?.query ?? {};
|
|
24296
|
+
const pending = session?.get(_Sso.PENDING_KEY);
|
|
24297
|
+
session?.delete(_Sso.PENDING_KEY);
|
|
24298
|
+
if (!pending || !values.code || !_Sso.equal(values.state, pending.state)) throw new SsoError("OIDC callback state is invalid or already consumed");
|
|
24299
|
+
if (Math.floor(Date.now() / 1e3) - Number(pending.created_at ?? 0) > 600) throw new SsoError("OIDC callback state has expired");
|
|
24300
|
+
const metadata = await this.discover();
|
|
24301
|
+
const tokens = await this.requestJson(metadata.token_endpoint, {
|
|
24302
|
+
grant_type: "authorization_code",
|
|
24303
|
+
code: values.code,
|
|
24304
|
+
redirect_uri: this.redirectUri,
|
|
24305
|
+
client_id: this.clientId,
|
|
24306
|
+
code_verifier: pending.verifier
|
|
24307
|
+
}, void 0, Boolean(this.clientSecret));
|
|
24308
|
+
if (!tokens.access_token || !tokens.id_token) throw new SsoError("OIDC token response is incomplete");
|
|
24309
|
+
if (this.verify === "jwks") throw new SsoError("JWKS verification requires an installed cryptography capability");
|
|
24310
|
+
const claims = await this.introspect(tokens.access_token);
|
|
24311
|
+
if (!_Sso.equal(_Sso.jwtPayload(tokens.id_token).nonce, pending.nonce)) throw new SsoError("OIDC ID token nonce mismatch");
|
|
24312
|
+
if (metadata.userinfo_endpoint) Object.assign(claims, await this.requestJson(metadata.userinfo_endpoint, void 0, tokens.access_token));
|
|
24313
|
+
const identity = this.normalize(claims);
|
|
24314
|
+
session.regenerate();
|
|
24315
|
+
session.set(_Sso.SESSION_KEY, {
|
|
24316
|
+
version: 1,
|
|
24317
|
+
identity,
|
|
24318
|
+
access_token: tokens.access_token,
|
|
24319
|
+
refresh_token: tokens.refresh_token,
|
|
24320
|
+
id_token: tokens.id_token,
|
|
24321
|
+
expires_at: Math.floor(Date.now() / 1e3) + Number(tokens.expires_in ?? 0)
|
|
24322
|
+
});
|
|
24323
|
+
return { identity, return_to: _Sso.safeReturn(pending.return_to) };
|
|
24324
|
+
}
|
|
24325
|
+
identity(requestOrSession) {
|
|
24326
|
+
const stored = this.session(requestOrSession)?.get(_Sso.SESSION_KEY);
|
|
24327
|
+
const identity = stored?.identity ?? null;
|
|
24328
|
+
if (identity && requestOrSession?.session) requestOrSession.user = identity;
|
|
24329
|
+
return identity;
|
|
24330
|
+
}
|
|
24331
|
+
async refresh(requestOrSession) {
|
|
24332
|
+
const session = this.session(requestOrSession);
|
|
24333
|
+
const stored = session?.get(_Sso.SESSION_KEY);
|
|
24334
|
+
if (!stored?.refresh_token) {
|
|
24335
|
+
session?.delete(_Sso.SESSION_KEY);
|
|
24336
|
+
throw new SsoError("OIDC session cannot be refreshed");
|
|
24337
|
+
}
|
|
24338
|
+
try {
|
|
24339
|
+
const metadata = await this.discover();
|
|
24340
|
+
const tokens = await this.requestJson(metadata.token_endpoint, {
|
|
24341
|
+
grant_type: "refresh_token",
|
|
24342
|
+
refresh_token: stored.refresh_token,
|
|
24343
|
+
client_id: this.clientId
|
|
24344
|
+
}, void 0, Boolean(this.clientSecret));
|
|
24345
|
+
const claims = await this.introspect(tokens.access_token);
|
|
24346
|
+
if (metadata.userinfo_endpoint) Object.assign(claims, await this.requestJson(metadata.userinfo_endpoint, void 0, tokens.access_token));
|
|
24347
|
+
const identity = this.normalize(claims);
|
|
24348
|
+
session.set(_Sso.SESSION_KEY, {
|
|
24349
|
+
...stored,
|
|
24350
|
+
identity,
|
|
24351
|
+
access_token: tokens.access_token,
|
|
24352
|
+
refresh_token: tokens.refresh_token ?? stored.refresh_token,
|
|
24353
|
+
id_token: tokens.id_token ?? stored.id_token,
|
|
24354
|
+
expires_at: Math.floor(Date.now() / 1e3) + Number(tokens.expires_in ?? 0)
|
|
24355
|
+
});
|
|
24356
|
+
return identity;
|
|
24357
|
+
} catch (error) {
|
|
24358
|
+
session?.delete(_Sso.SESSION_KEY);
|
|
24359
|
+
throw error;
|
|
24360
|
+
}
|
|
24361
|
+
}
|
|
24362
|
+
async logout(requestOrSession, returnTo = "/") {
|
|
24363
|
+
const session = this.session(requestOrSession);
|
|
24364
|
+
const stored = session?.get(_Sso.SESSION_KEY);
|
|
24365
|
+
session?.destroy();
|
|
24366
|
+
const endpoint = (await this.discover()).end_session_endpoint;
|
|
24367
|
+
const target = this.postLogoutRedirectUri ?? _Sso.safeReturn(returnTo);
|
|
24368
|
+
if (!endpoint) return target;
|
|
24369
|
+
const params = new URLSearchParams({ post_logout_redirect_uri: target, client_id: this.clientId });
|
|
24370
|
+
if (stored?.id_token) params.set("id_token_hint", stored.id_token);
|
|
24371
|
+
return `${endpoint}?${params}`;
|
|
24372
|
+
}
|
|
24373
|
+
static async mountConfigured(router) {
|
|
24374
|
+
if (_Sso.mountedRouters.has(router) || !_Sso.configured()) return false;
|
|
24375
|
+
const owned = /* @__PURE__ */ new Set(["GET /auth/login", "GET /auth/callback", "POST /auth/logout"]);
|
|
24376
|
+
const collisions = router.getRoutes().map((route) => `${route.method} ${route.pattern}`).filter((route) => owned.has(route));
|
|
24377
|
+
if (collisions.length) throw new SsoError(`SSO route collision: ${collisions.join(", ")}`);
|
|
24378
|
+
const sso = await _Sso.fromIssuer();
|
|
24379
|
+
router.get("/auth/login", async (req2, res) => res.redirect(await sso.login(req2, req2.query?.return_to ?? "/")));
|
|
24380
|
+
router.get("/auth/callback", async (req2, res) => {
|
|
24381
|
+
try {
|
|
24382
|
+
return res.redirect((await sso.callback(req2)).return_to);
|
|
24383
|
+
} catch (error) {
|
|
24384
|
+
const message = error instanceof SsoError ? error.message : "OIDC callback failed";
|
|
24385
|
+
return res.error("SSO_CALLBACK_FAILED", message, 400);
|
|
24386
|
+
}
|
|
24387
|
+
});
|
|
24388
|
+
router.post("/auth/logout", async (req2, res) => res.redirect(await sso.logout(req2, req2.query?.return_to ?? "/")));
|
|
24389
|
+
_Sso.mountedRouters.add(router);
|
|
24390
|
+
return true;
|
|
24391
|
+
}
|
|
24392
|
+
};
|
|
24393
|
+
}
|
|
24394
|
+
});
|
|
24395
|
+
|
|
23822
24396
|
// ../core/src/docsAutoDiscovery.ts
|
|
23823
24397
|
var docsAutoDiscovery_exports = {};
|
|
23824
24398
|
__export(docsAutoDiscovery_exports, {
|
|
@@ -23888,7 +24462,7 @@ var init_docsAutoDiscovery = __esm({
|
|
|
23888
24462
|
|
|
23889
24463
|
// ../core/src/server.ts
|
|
23890
24464
|
import { createServer as createServer2 } from "node:http";
|
|
23891
|
-
import { randomBytes as
|
|
24465
|
+
import { randomBytes as randomBytes6 } from "node:crypto";
|
|
23892
24466
|
import { resolve as resolve14, dirname as dirname11, join as join24, relative as relative8 } from "node:path";
|
|
23893
24467
|
import { existsSync as existsSync22, readdirSync as readdirSync14, readFileSync as readFileSync21, statSync as statSync15 } from "node:fs";
|
|
23894
24468
|
import { isatty } from "node:tty";
|
|
@@ -24528,7 +25102,7 @@ async function renderDispatchError(err, req2, res, templatesDir) {
|
|
|
24528
25102
|
}
|
|
24529
25103
|
}
|
|
24530
25104
|
}
|
|
24531
|
-
const requestId = Log.getRequestId() ??
|
|
25105
|
+
const requestId = Log.getRequestId() ?? randomBytes6(4).toString("hex");
|
|
24532
25106
|
if (wantsJson(req2)) {
|
|
24533
25107
|
const body = negotiatedErrorBody(500, "Internal Server Error", requestId);
|
|
24534
25108
|
res.raw.writeHead(500, { "Content-Type": "application/json" });
|
|
@@ -24599,7 +25173,7 @@ function serveStaticAsset(ctx) {
|
|
|
24599
25173
|
return false;
|
|
24600
25174
|
}
|
|
24601
25175
|
async function serveNotFound(ctx) {
|
|
24602
|
-
const requestId = Log.getRequestId() ??
|
|
25176
|
+
const requestId = Log.getRequestId() ?? randomBytes6(4).toString("hex");
|
|
24603
25177
|
if (wantsJson(ctx.req)) {
|
|
24604
25178
|
const body = negotiatedErrorBody(404, "Not Found", requestId);
|
|
24605
25179
|
ctx.res.raw.writeHead(404, httpReason(404), { "Content-Type": "application/json" });
|
|
@@ -24719,7 +25293,7 @@ async function dispatchInner(ctx, rawReq, rawRes, requestId) {
|
|
|
24719
25293
|
}
|
|
24720
25294
|
}
|
|
24721
25295
|
async function runDispatch(ctx, rawReq, rawRes) {
|
|
24722
|
-
const requestId = Log.sanitizeRequestId(rawReq.headers["x-request-id"]) ??
|
|
25296
|
+
const requestId = Log.sanitizeRequestId(rawReq.headers["x-request-id"]) ?? randomBytes6(4).toString("hex");
|
|
24723
25297
|
if (!rawRes.headersSent) rawRes.setHeader("x-request-id", requestId);
|
|
24724
25298
|
return Log.runWithRequestId(requestId, () => dispatchInner(ctx, rawReq, rawRes, requestId));
|
|
24725
25299
|
}
|
|
@@ -24853,6 +25427,8 @@ ${reset2}
|
|
|
24853
25427
|
console.log(`
|
|
24854
25428
|
No routes directory found at ${routesDir}`);
|
|
24855
25429
|
}
|
|
25430
|
+
const { Sso: Sso2 } = await Promise.resolve().then(() => (init_sso(), sso_exports));
|
|
25431
|
+
await Sso2.mountConfigured(router);
|
|
24856
25432
|
if (attachCsrfFromEnv()) {
|
|
24857
25433
|
console.log(`
|
|
24858
25434
|
\x1B[36mCSRF\x1B[0m protection enabled (TINA4_CSRF)`);
|
|
@@ -25845,7 +26421,7 @@ var init_mqttMessage = __esm({
|
|
|
25845
26421
|
// ../core/src/mqtt.ts
|
|
25846
26422
|
import net2 from "node:net";
|
|
25847
26423
|
import tls from "node:tls";
|
|
25848
|
-
import { randomBytes as
|
|
26424
|
+
import { randomBytes as randomBytes7 } from "node:crypto";
|
|
25849
26425
|
import { existsSync as existsSync24, readFileSync as readFileSync22 } from "node:fs";
|
|
25850
26426
|
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;
|
|
25851
26427
|
var init_mqtt = __esm({
|
|
@@ -25933,7 +26509,7 @@ var init_mqtt = __esm({
|
|
|
25933
26509
|
this.caFile = options.caFile ?? (Env.str("TINA4_MQTT_CA_FILE") || null);
|
|
25934
26510
|
this.tlsVerify = options.tlsVerify ?? Env.bool("TINA4_MQTT_TLS_VERIFY", true);
|
|
25935
26511
|
let cid = options.clientId ?? (Env.str("TINA4_MQTT_CLIENT_ID") || null);
|
|
25936
|
-
if (cid === null || cid === "") cid = "tina4-" +
|
|
26512
|
+
if (cid === null || cid === "") cid = "tina4-" + randomBytes7(8).toString("hex");
|
|
25937
26513
|
this.clientId = cid;
|
|
25938
26514
|
this.keepalive = options.keepalive ?? Env.int("TINA4_MQTT_KEEPALIVE", DEFAULT_KEEPALIVE);
|
|
25939
26515
|
this.cleanSession = options.cleanSession ?? true;
|
|
@@ -26863,7 +27439,7 @@ var init_service = __esm({
|
|
|
26863
27439
|
import http from "node:http";
|
|
26864
27440
|
import https from "node:https";
|
|
26865
27441
|
import { URL as URL2 } from "node:url";
|
|
26866
|
-
import { randomBytes as
|
|
27442
|
+
import { randomBytes as randomBytes8 } from "node:crypto";
|
|
26867
27443
|
import { promises as fsp, createWriteStream } from "node:fs";
|
|
26868
27444
|
import { basename as basename5 } from "node:path";
|
|
26869
27445
|
import { pipeline } from "node:stream/promises";
|
|
@@ -27161,7 +27737,7 @@ var init_api = __esm({
|
|
|
27161
27737
|
return { http_code: null, body: null, headers: {}, error: "upload requires filePath or fileBytes" };
|
|
27162
27738
|
}
|
|
27163
27739
|
const partContentType = guessContentType(uploadName);
|
|
27164
|
-
const boundary = "----Tina4Boundary" +
|
|
27740
|
+
const boundary = "----Tina4Boundary" + randomBytes8(16).toString("hex");
|
|
27165
27741
|
const bodyBuffer = buildMultipartBody(boundary, fieldName, uploadName, content, partContentType, extraFields);
|
|
27166
27742
|
const contentType = `multipart/form-data; boundary=${boundary}`;
|
|
27167
27743
|
return this.execute("POST", this.buildUrl(path8), bodyBuffer, contentType, headers);
|
|
@@ -31993,10 +32569,13 @@ __export(src_exports2, {
|
|
|
31993
32569
|
RouteGroup: () => RouteGroup,
|
|
31994
32570
|
RouteRef: () => RouteRef,
|
|
31995
32571
|
Router: () => Router,
|
|
32572
|
+
SSO: () => Sso,
|
|
31996
32573
|
SafeString: () => SafeString2,
|
|
31997
32574
|
SecurityHeadersMiddleware: () => SecurityHeadersMiddleware,
|
|
31998
32575
|
ServiceRunner: () => ServiceRunner,
|
|
31999
32576
|
Session: () => Session,
|
|
32577
|
+
Sso: () => Sso,
|
|
32578
|
+
SsoError: () => SsoError,
|
|
32000
32579
|
TAKEOVER_KILLED: () => TAKEOVER_KILLED,
|
|
32001
32580
|
TAKEOVER_NOTHING: () => TAKEOVER_NOTHING,
|
|
32002
32581
|
TAKEOVER_REFUSALS: () => TAKEOVER_REFUSALS,
|
|
@@ -32233,6 +32812,7 @@ var init_src2 = __esm({
|
|
|
32233
32812
|
init_htmlElement();
|
|
32234
32813
|
init_errorOverlay();
|
|
32235
32814
|
init_ai();
|
|
32815
|
+
init_sso();
|
|
32236
32816
|
init_aiClient();
|
|
32237
32817
|
init_liteBackend();
|
|
32238
32818
|
init_rabbitmqBackend();
|
|
@@ -33331,6 +33911,8 @@ function fieldTypeToPostgres(def) {
|
|
|
33331
33911
|
return "TEXT";
|
|
33332
33912
|
case "json":
|
|
33333
33913
|
return "JSONB";
|
|
33914
|
+
case "point":
|
|
33915
|
+
return SQLTranslator.pointColumnType("postgres", def.srid ?? 4326);
|
|
33334
33916
|
case "string":
|
|
33335
33917
|
return def.maxLength ? `VARCHAR(${def.maxLength})` : "VARCHAR(255)";
|
|
33336
33918
|
default:
|
|
@@ -37847,7 +38429,9 @@ function buildAddColumnSql(adapter, table2, colName, def) {
|
|
|
37847
38429
|
return sql;
|
|
37848
38430
|
}
|
|
37849
38431
|
function mt(db) {
|
|
37850
|
-
|
|
38432
|
+
const engine = engineOf(db);
|
|
38433
|
+
if (engine === "firebird") return MIGRATION_TABLE;
|
|
38434
|
+
return engine === "mysql" ? `\`${MIGRATION_TABLE}\`` : `"${MIGRATION_TABLE}"`;
|
|
37851
38435
|
}
|
|
37852
38436
|
function deriveDescription(name) {
|
|
37853
38437
|
return name.replace(/^\d+_/, "").replace(/_/g, " ");
|
|
@@ -37870,9 +38454,9 @@ async function ensureMigrationTableOn(db) {
|
|
|
37870
38454
|
id INTEGER NOT NULL PRIMARY KEY,
|
|
37871
38455
|
migration_name VARCHAR(500) NOT NULL UNIQUE,
|
|
37872
38456
|
description VARCHAR(500),
|
|
37873
|
-
batch INTEGER NOT NULL
|
|
38457
|
+
batch INTEGER DEFAULT 1 NOT NULL,
|
|
37874
38458
|
executed_at VARCHAR(50) NOT NULL,
|
|
37875
|
-
passed INTEGER NOT NULL
|
|
38459
|
+
passed INTEGER DEFAULT 1 NOT NULL
|
|
37876
38460
|
)`);
|
|
37877
38461
|
} else {
|
|
37878
38462
|
const idCol = migrationIdColumn(db);
|
|
@@ -37969,7 +38553,7 @@ async function recordApplied(db, name, batch, passed = 1) {
|
|
|
37969
38553
|
"SELECT GEN_ID(GEN_TINA4_MIGRATION_ID, 1) AS NEXT_ID FROM RDB$DATABASE"
|
|
37970
38554
|
);
|
|
37971
38555
|
insertCols.unshift("id");
|
|
37972
|
-
values.unshift(rows[0]?.
|
|
38556
|
+
values.unshift(rows[0]?.next_id ?? 1);
|
|
37973
38557
|
}
|
|
37974
38558
|
const placeholders = insertCols.map(() => "?").join(", ");
|
|
37975
38559
|
await adapterExecute(
|
|
@@ -38954,10 +39538,13 @@ var init_queryBuilder = __esm({
|
|
|
38954
39538
|
"use strict";
|
|
38955
39539
|
init_database();
|
|
38956
39540
|
init_databaseResult();
|
|
39541
|
+
init_point();
|
|
39542
|
+
init_sqlTranslator();
|
|
38957
39543
|
QueryBuilder = class _QueryBuilder {
|
|
38958
39544
|
table;
|
|
38959
39545
|
db;
|
|
38960
39546
|
columns = ["*"];
|
|
39547
|
+
selectParams = [];
|
|
38961
39548
|
wheres = [];
|
|
38962
39549
|
params = [];
|
|
38963
39550
|
joinClauses = [];
|
|
@@ -38965,14 +39552,17 @@ var init_queryBuilder = __esm({
|
|
|
38965
39552
|
havings = [];
|
|
38966
39553
|
havingParams = [];
|
|
38967
39554
|
orderByCols = [];
|
|
39555
|
+
orderByParams = [];
|
|
39556
|
+
primaryKey;
|
|
38968
39557
|
limitVal;
|
|
38969
39558
|
offsetVal;
|
|
38970
39559
|
/**
|
|
38971
39560
|
* Private constructor — use static factory methods.
|
|
38972
39561
|
*/
|
|
38973
|
-
constructor(table2, db) {
|
|
39562
|
+
constructor(table2, db, primaryKey) {
|
|
38974
39563
|
this.table = table2;
|
|
38975
39564
|
this.db = db;
|
|
39565
|
+
this.primaryKey = primaryKey;
|
|
38976
39566
|
}
|
|
38977
39567
|
/**
|
|
38978
39568
|
* Create a QueryBuilder for a table.
|
|
@@ -38981,8 +39571,8 @@ var init_queryBuilder = __esm({
|
|
|
38981
39571
|
* @param db - Optional database adapter.
|
|
38982
39572
|
* @returns A new QueryBuilder instance.
|
|
38983
39573
|
*/
|
|
38984
|
-
static fromTable(tableName, db) {
|
|
38985
|
-
return new _QueryBuilder(tableName, db);
|
|
39574
|
+
static fromTable(tableName, db, primaryKey) {
|
|
39575
|
+
return new _QueryBuilder(tableName, db, primaryKey);
|
|
38986
39576
|
}
|
|
38987
39577
|
/**
|
|
38988
39578
|
* Set the columns to select.
|
|
@@ -38993,6 +39583,7 @@ var init_queryBuilder = __esm({
|
|
|
38993
39583
|
select(...cols) {
|
|
38994
39584
|
if (cols.length > 0) {
|
|
38995
39585
|
this.columns = cols;
|
|
39586
|
+
this.selectParams = [];
|
|
38996
39587
|
}
|
|
38997
39588
|
return this;
|
|
38998
39589
|
}
|
|
@@ -39074,6 +39665,41 @@ var init_queryBuilder = __esm({
|
|
|
39074
39665
|
this.orderByCols.push(expression);
|
|
39075
39666
|
return this;
|
|
39076
39667
|
}
|
|
39668
|
+
withinDistance(column2, pointValue, radiusMetres, srid = DEFAULT_SRID) {
|
|
39669
|
+
const radius = Number(radiusMetres);
|
|
39670
|
+
if (!Number.isFinite(radius) || radius < 0) throw new RangeError("Spatial radius must be finite and greater than or equal to zero");
|
|
39671
|
+
const point = Point.parse(pointValue, srid);
|
|
39672
|
+
return this.where(SQLTranslator.withinDistance(this.engine(), column2, point.srid), [point.lon, point.lat, radius]);
|
|
39673
|
+
}
|
|
39674
|
+
intersects(column2, geometry, srid = DEFAULT_SRID) {
|
|
39675
|
+
const [bound, form] = Point.geometryBinding(geometry, srid);
|
|
39676
|
+
return this.where(SQLTranslator.intersects(this.engine(), column2, form, srid), [bound]);
|
|
39677
|
+
}
|
|
39678
|
+
bbox(column2, minLon, minLat, maxLon, maxLat, srid = DEFAULT_SRID) {
|
|
39679
|
+
const values = [minLon, minLat, maxLon, maxLat].map(Number);
|
|
39680
|
+
if (!values.every(Number.isFinite)) throw new TypeError("Bounding-box coordinates must be finite numbers");
|
|
39681
|
+
const [west, south, east, north] = values;
|
|
39682
|
+
new Point(west, south, srid);
|
|
39683
|
+
new Point(east, north, srid);
|
|
39684
|
+
if (west > east || south > north) throw new RangeError("Bounding box must be ordered west, south, east, north");
|
|
39685
|
+
return this.where(SQLTranslator.bbox(this.engine(), column2, srid), values);
|
|
39686
|
+
}
|
|
39687
|
+
selectDistance(column2, pointValue, alias = "distance", srid = DEFAULT_SRID) {
|
|
39688
|
+
const point = Point.parse(pointValue, srid);
|
|
39689
|
+
this.columns.push(SQLTranslator.distanceAs(this.engine(), column2, alias, point.srid));
|
|
39690
|
+
this.selectParams.push(point.lon, point.lat);
|
|
39691
|
+
return this;
|
|
39692
|
+
}
|
|
39693
|
+
orderByDistance(column2, pointValue, direction = "ASC", srid = DEFAULT_SRID) {
|
|
39694
|
+
const order = direction.toUpperCase();
|
|
39695
|
+
if (order !== "ASC" && order !== "DESC") throw new TypeError("Distance order direction must be ASC or DESC");
|
|
39696
|
+
if (!this.primaryKey) throw new Error("Stable spatial ordering needs a primary key; use BaseModel.query() or pass one to fromTable()");
|
|
39697
|
+
const point = Point.parse(pointValue, srid);
|
|
39698
|
+
this.orderByCols.push(`${SQLTranslator.distance(this.engine(), column2, point.srid)} ${order}`);
|
|
39699
|
+
this.orderByParams.push(point.lon, point.lat);
|
|
39700
|
+
this.orderByCols.push(`${SQLTranslator.spatialIdentifier(this.primaryKey, "primary key")} ASC`);
|
|
39701
|
+
return this;
|
|
39702
|
+
}
|
|
39077
39703
|
/**
|
|
39078
39704
|
* Set LIMIT and optional OFFSET.
|
|
39079
39705
|
*
|
|
@@ -39139,7 +39765,7 @@ var init_queryBuilder = __esm({
|
|
|
39139
39765
|
async get() {
|
|
39140
39766
|
this.ensureDb();
|
|
39141
39767
|
const sql = this.toSql();
|
|
39142
|
-
const allParams = [...this.params, ...this.havingParams];
|
|
39768
|
+
const allParams = [...this.selectParams, ...this.params, ...this.havingParams, ...this.orderByParams];
|
|
39143
39769
|
const queryParams = allParams.length > 0 ? allParams : void 0;
|
|
39144
39770
|
const rows = await adapterFetch(
|
|
39145
39771
|
this.db,
|
|
@@ -39167,7 +39793,7 @@ var init_queryBuilder = __esm({
|
|
|
39167
39793
|
async first() {
|
|
39168
39794
|
this.ensureDb();
|
|
39169
39795
|
const sql = this.toSql();
|
|
39170
|
-
const allParams = [...this.params, ...this.havingParams];
|
|
39796
|
+
const allParams = [...this.selectParams, ...this.params, ...this.havingParams, ...this.orderByParams];
|
|
39171
39797
|
return adapterFetchOne(
|
|
39172
39798
|
this.db,
|
|
39173
39799
|
sql,
|
|
@@ -39182,9 +39808,18 @@ var init_queryBuilder = __esm({
|
|
|
39182
39808
|
async count() {
|
|
39183
39809
|
this.ensureDb();
|
|
39184
39810
|
const original = this.columns;
|
|
39811
|
+
const originalSelectParams = this.selectParams;
|
|
39812
|
+
const originalOrder = this.orderByCols;
|
|
39813
|
+
const originalOrderParams = this.orderByParams;
|
|
39185
39814
|
this.columns = ["COUNT(*) as cnt"];
|
|
39815
|
+
this.selectParams = [];
|
|
39816
|
+
this.orderByCols = [];
|
|
39817
|
+
this.orderByParams = [];
|
|
39186
39818
|
const sql = this.toSql();
|
|
39187
39819
|
this.columns = original;
|
|
39820
|
+
this.selectParams = originalSelectParams;
|
|
39821
|
+
this.orderByCols = originalOrder;
|
|
39822
|
+
this.orderByParams = originalOrderParams;
|
|
39188
39823
|
const allParams = [...this.params, ...this.havingParams];
|
|
39189
39824
|
const row = await adapterFetchOne(
|
|
39190
39825
|
this.db,
|
|
@@ -39369,6 +40004,10 @@ var init_queryBuilder = __esm({
|
|
|
39369
40004
|
}
|
|
39370
40005
|
}
|
|
39371
40006
|
}
|
|
40007
|
+
engine() {
|
|
40008
|
+
this.ensureDb();
|
|
40009
|
+
return this.db.getDatabaseType();
|
|
40010
|
+
}
|
|
39372
40011
|
};
|
|
39373
40012
|
}
|
|
39374
40013
|
});
|
|
@@ -39392,6 +40031,11 @@ function toDbFieldValue(def, value) {
|
|
|
39392
40031
|
if (def?.type === "json" && value !== null && value !== void 0 && typeof value !== "string") {
|
|
39393
40032
|
return JSON.stringify(value);
|
|
39394
40033
|
}
|
|
40034
|
+
if (def?.type === "point" && value !== null && value !== void 0) {
|
|
40035
|
+
const point = Point.parse(value, def.srid ?? DEFAULT_SRID);
|
|
40036
|
+
if (point.srid !== (def.srid ?? DEFAULT_SRID)) throw new TypeError(`Point field expects SRID ${def.srid ?? DEFAULT_SRID}; received ${point.srid}`);
|
|
40037
|
+
return point.ewkt;
|
|
40038
|
+
}
|
|
39395
40039
|
return value;
|
|
39396
40040
|
}
|
|
39397
40041
|
function fromDbFieldValue(def, value) {
|
|
@@ -39402,6 +40046,11 @@ function fromDbFieldValue(def, value) {
|
|
|
39402
40046
|
return value;
|
|
39403
40047
|
}
|
|
39404
40048
|
}
|
|
40049
|
+
if (def?.type === "point" && value !== null && value !== void 0) {
|
|
40050
|
+
const point = Point.parse(value, def.srid ?? DEFAULT_SRID);
|
|
40051
|
+
if (point.srid !== (def.srid ?? DEFAULT_SRID)) throw new TypeError(`Point field expects SRID ${def.srid ?? DEFAULT_SRID}; received ${point.srid}`);
|
|
40052
|
+
return point;
|
|
40053
|
+
}
|
|
39405
40054
|
return value;
|
|
39406
40055
|
}
|
|
39407
40056
|
function _pluralRelKeys() {
|
|
@@ -39437,6 +40086,7 @@ var init_baseModel = __esm({
|
|
|
39437
40086
|
init_sqlite();
|
|
39438
40087
|
init_sqlTranslator();
|
|
39439
40088
|
init_src2();
|
|
40089
|
+
init_point();
|
|
39440
40090
|
_fkRegistry = /* @__PURE__ */ new Map();
|
|
39441
40091
|
EAGER_IN_CHUNK = 500;
|
|
39442
40092
|
modelQueryCache = new QueryCache({ defaultTtl: 0, maxSize: 500 });
|
|
@@ -39492,7 +40142,9 @@ var init_baseModel = __esm({
|
|
|
39492
40142
|
for (const [name, def] of Object.entries(fields0)) {
|
|
39493
40143
|
if (def.default === void 0) continue;
|
|
39494
40144
|
let dv = typeof def.default === "function" ? def.default() : def.default;
|
|
39495
|
-
if (dv !== null &&
|
|
40145
|
+
if (def.type === "point" && dv !== null && dv !== void 0) {
|
|
40146
|
+
dv = fromDbFieldValue(def, dv);
|
|
40147
|
+
} else if (dv !== null && typeof dv === "object") dv = structuredClone(dv);
|
|
39496
40148
|
this[name] = dv;
|
|
39497
40149
|
}
|
|
39498
40150
|
if (data) {
|
|
@@ -39594,7 +40246,7 @@ var init_baseModel = __esm({
|
|
|
39594
40246
|
* @returns A QueryBuilder instance bound to this model's table and database.
|
|
39595
40247
|
*/
|
|
39596
40248
|
static query() {
|
|
39597
|
-
return QueryBuilder.fromTable(this.tableName, this.getDb());
|
|
40249
|
+
return QueryBuilder.fromTable(this.tableName, this.getDb(), this.getPkColumn());
|
|
39598
40250
|
}
|
|
39599
40251
|
/**
|
|
39600
40252
|
* Get the database adapter for this model.
|
|
@@ -40047,7 +40699,7 @@ var init_baseModel = __esm({
|
|
|
40047
40699
|
for (const key of Object.keys(ModelClass.fields)) {
|
|
40048
40700
|
if (this[key] !== void 0) {
|
|
40049
40701
|
const outKey = case_ === "snake" ? ModelClass.fieldMapping[key] ?? key : key;
|
|
40050
|
-
result[outKey] = this[key];
|
|
40702
|
+
result[outKey] = this[key] instanceof Point ? this[key].geojson : this[key];
|
|
40051
40703
|
}
|
|
40052
40704
|
}
|
|
40053
40705
|
if (ModelClass.softDelete && this.is_deleted !== void 0) {
|
|
@@ -40107,6 +40759,19 @@ var init_baseModel = __esm({
|
|
|
40107
40759
|
}
|
|
40108
40760
|
return result;
|
|
40109
40761
|
}
|
|
40762
|
+
toFeature(geometryField, include) {
|
|
40763
|
+
const ModelClass = this.constructor;
|
|
40764
|
+
const pointFields = Object.entries(ModelClass.fields).filter(([, def]) => def.type === "point").map(([name]) => name);
|
|
40765
|
+
const field = geometryField ?? pointFields[0];
|
|
40766
|
+
if (!field || !pointFields.includes(field)) throw new Error("toFeature() needs a declared point field");
|
|
40767
|
+
const properties = this.toDict(include, "camel");
|
|
40768
|
+
const geometry = properties[field] ?? null;
|
|
40769
|
+
delete properties[field];
|
|
40770
|
+
return { type: "Feature", geometry, properties };
|
|
40771
|
+
}
|
|
40772
|
+
static featureCollection(models, geometryField, include) {
|
|
40773
|
+
return { type: "FeatureCollection", features: models.map((model) => model.toFeature(geometryField, include)) };
|
|
40774
|
+
}
|
|
40110
40775
|
/**
|
|
40111
40776
|
* Convert to an associative object (alias for toDict).
|
|
40112
40777
|
*/
|
|
@@ -40157,7 +40822,10 @@ var init_baseModel = __esm({
|
|
|
40157
40822
|
*/
|
|
40158
40823
|
static async createTable() {
|
|
40159
40824
|
const db = this.getDb();
|
|
40160
|
-
|
|
40825
|
+
const pointFields = Object.entries(this.fields).filter(([, def]) => def.type === "point");
|
|
40826
|
+
const engine = db.getDatabaseType();
|
|
40827
|
+
if (pointFields.length > 0) SQLTranslator.requireSpatial(engine, "PointField");
|
|
40828
|
+
if (await adapterTableExists(db, this.tableName)) return this.createSpatialIndexes(db, pointFields);
|
|
40161
40829
|
if (typeof db.createTable === "function" || typeof db.createTableAsync === "function") {
|
|
40162
40830
|
const mappedFields = {};
|
|
40163
40831
|
for (const [fieldName, def] of Object.entries(this.fields)) {
|
|
@@ -40173,7 +40841,7 @@ var init_baseModel = __esm({
|
|
|
40173
40841
|
mappedFields["is_deleted"] = { type: "integer", default: 0 };
|
|
40174
40842
|
}
|
|
40175
40843
|
await adapterCreateTable(db, this.tableName, mappedFields);
|
|
40176
|
-
return
|
|
40844
|
+
return this.createSpatialIndexes(db, pointFields);
|
|
40177
40845
|
}
|
|
40178
40846
|
const typeMap = {
|
|
40179
40847
|
integer: "INTEGER",
|
|
@@ -40220,6 +40888,14 @@ var init_baseModel = __esm({
|
|
|
40220
40888
|
}
|
|
40221
40889
|
return true;
|
|
40222
40890
|
}
|
|
40891
|
+
static async createSpatialIndexes(db, fields) {
|
|
40892
|
+
for (const [fieldName, def] of fields) {
|
|
40893
|
+
SQLTranslator.pointColumnType(db.getDatabaseType(), def.srid ?? DEFAULT_SRID);
|
|
40894
|
+
if (def.spatialIndex === false) continue;
|
|
40895
|
+
await adapterExecute(db, SQLTranslator.spatialIndex(db.getDatabaseType(), this.tableName, this.getDbColumn(fieldName)));
|
|
40896
|
+
}
|
|
40897
|
+
return true;
|
|
40898
|
+
}
|
|
40223
40899
|
/**
|
|
40224
40900
|
* Find a record by primary key or throw an error if not found.
|
|
40225
40901
|
*/
|
|
@@ -40291,15 +40967,25 @@ var init_baseModel = __esm({
|
|
|
40291
40967
|
/**
|
|
40292
40968
|
* Invalidate every cached query that touches this model's table.
|
|
40293
40969
|
*
|
|
40294
|
-
* Tag-scoped
|
|
40295
|
-
* this table is busted too
|
|
40296
|
-
* never touches this table is left intact
|
|
40297
|
-
*
|
|
40298
|
-
*
|
|
40970
|
+
* Tag-scoped in the ORM layer (a cached JOIN on another model that reads
|
|
40971
|
+
* this table is busted too because it carries this table's tag; a query
|
|
40972
|
+
* that never touches this table is left intact), then cascaded to the
|
|
40973
|
+
* DB layer on this model's bound connection so an out-of-band write /
|
|
40974
|
+
* deliberate refresh / race-with-another-process cannot leave stale rows
|
|
40975
|
+
* in db.fetch()'s persistent cache. Called after every ORM write
|
|
40976
|
+
* (save/delete/forceDelete/restore) so a read-after-write never serves
|
|
40977
|
+
* a stale/deleted row (CACHE-DEC-01). PY-06-22 (3.13.105) added the
|
|
40978
|
+
* DB-layer cascade -- previously the two cache layers disagreed under
|
|
40979
|
+
* TINA4_AUTO_CACHING=true + TINA4_DB_CACHE=true.
|
|
40299
40980
|
*/
|
|
40300
40981
|
static clearCache() {
|
|
40301
40982
|
const ModelClass = this;
|
|
40302
40983
|
modelQueryCache.clearTag((ModelClass.tableName ?? "").toLowerCase());
|
|
40984
|
+
try {
|
|
40985
|
+
const db = ModelClass.getDb();
|
|
40986
|
+
if (typeof db?.cacheClear === "function") db.cacheClear();
|
|
40987
|
+
} catch {
|
|
40988
|
+
}
|
|
40303
40989
|
}
|
|
40304
40990
|
/**
|
|
40305
40991
|
* Execute a raw SQL SELECT and return results as model instances.
|
|
@@ -41159,7 +41845,7 @@ var init_seeder = __esm({
|
|
|
41159
41845
|
|
|
41160
41846
|
// src/docstore.ts
|
|
41161
41847
|
import { DatabaseSync as DatabaseSync4 } from "node:sqlite";
|
|
41162
|
-
import { randomBytes as
|
|
41848
|
+
import { randomBytes as randomBytes9 } from "node:crypto";
|
|
41163
41849
|
import { mkdirSync as mkdirSync19 } from "node:fs";
|
|
41164
41850
|
import { dirname as dirname14, isAbsolute as isAbsolute6, join as join31 } from "node:path";
|
|
41165
41851
|
function iso(d) {
|
|
@@ -41511,8 +42197,8 @@ var init_docstore = __esm({
|
|
|
41511
42197
|
OID_RE = /^[0-9a-fA-F]{24}$/;
|
|
41512
42198
|
ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/;
|
|
41513
42199
|
ObjectId = class _ObjectId {
|
|
41514
|
-
static _counter =
|
|
41515
|
-
static _process =
|
|
42200
|
+
static _counter = randomBytes9(3).readUIntBE(0, 3);
|
|
42201
|
+
static _process = randomBytes9(5);
|
|
41516
42202
|
_bytes;
|
|
41517
42203
|
constructor(oid) {
|
|
41518
42204
|
if (oid === void 0 || oid === null) {
|
|
@@ -41940,7 +42626,7 @@ var init_attachment = __esm({
|
|
|
41940
42626
|
});
|
|
41941
42627
|
|
|
41942
42628
|
// src/realtime/storage.ts
|
|
41943
|
-
import { randomBytes as
|
|
42629
|
+
import { randomBytes as randomBytes10 } from "node:crypto";
|
|
41944
42630
|
import { mkdirSync as mkdirSync20, readFileSync as readFileSync26, writeFileSync as writeFileSync17, unlinkSync as unlinkSync8, statSync as statSync18 } from "node:fs";
|
|
41945
42631
|
import { resolve as resolve19, sep as sep5 } from "node:path";
|
|
41946
42632
|
import { createRequire as createRequire8 } from "node:module";
|
|
@@ -41951,7 +42637,7 @@ function storageKey(filename = "") {
|
|
|
41951
42637
|
const clean = raw.replace(UNSAFE, "").slice(0, 12);
|
|
41952
42638
|
if (clean) ext = `.${clean}`;
|
|
41953
42639
|
}
|
|
41954
|
-
return `${
|
|
42640
|
+
return `${randomBytes10(16).toString("hex")}${ext}`;
|
|
41955
42641
|
}
|
|
41956
42642
|
function selectStorage(storage) {
|
|
41957
42643
|
if (storage) return storage;
|
|
@@ -42399,6 +43085,7 @@ __export(index_exports, {
|
|
|
42399
43085
|
CachedDatabaseAdapter: () => CachedDatabaseAdapter,
|
|
42400
43086
|
Cursor: () => Cursor,
|
|
42401
43087
|
DEFAULT_DATABASE_CONNECT_TIMEOUT_SECONDS: () => DEFAULT_DATABASE_CONNECT_TIMEOUT_SECONDS,
|
|
43088
|
+
DEFAULT_SRID: () => DEFAULT_SRID,
|
|
42402
43089
|
Database: () => Database,
|
|
42403
43090
|
DatabaseResult: () => DatabaseResult,
|
|
42404
43091
|
DatabaseUrl: () => DatabaseUrl,
|
|
@@ -42414,6 +43101,7 @@ __export(index_exports, {
|
|
|
42414
43101
|
NOT_REQUIRED_ON_ADAPTER: () => NOT_REQUIRED_ON_ADAPTER,
|
|
42415
43102
|
ObjectId: () => ObjectId,
|
|
42416
43103
|
OdbcAdapter: () => OdbcAdapter,
|
|
43104
|
+
Point: () => Point,
|
|
42417
43105
|
PostgresAdapter: () => PostgresAdapter,
|
|
42418
43106
|
QueryBuilder: () => QueryBuilder,
|
|
42419
43107
|
QueryCache: () => QueryCache,
|
|
@@ -42426,6 +43114,7 @@ __export(index_exports, {
|
|
|
42426
43114
|
S3Storage: () => S3Storage,
|
|
42427
43115
|
SQLTranslator: () => SQLTranslator,
|
|
42428
43116
|
SQLiteAdapter: () => SQLiteAdapter,
|
|
43117
|
+
SpatialNotSupportedError: () => SpatialNotSupportedError,
|
|
42429
43118
|
SqliteCollection: () => SqliteCollection,
|
|
42430
43119
|
SqliteDatabase: () => SqliteDatabase,
|
|
42431
43120
|
adapterColumns: () => adapterColumns,
|
|
@@ -42519,6 +43208,7 @@ var init_index = __esm({
|
|
|
42519
43208
|
init_baseModel();
|
|
42520
43209
|
init_queryBuilder();
|
|
42521
43210
|
init_sqlTranslator();
|
|
43211
|
+
init_point();
|
|
42522
43212
|
init_connectTimeout();
|
|
42523
43213
|
init_cachedDatabase();
|
|
42524
43214
|
init_fakeData2();
|
|
@@ -42542,6 +43232,7 @@ export {
|
|
|
42542
43232
|
CachedDatabaseAdapter,
|
|
42543
43233
|
Cursor,
|
|
42544
43234
|
DEFAULT_DATABASE_CONNECT_TIMEOUT_SECONDS,
|
|
43235
|
+
DEFAULT_SRID,
|
|
42545
43236
|
Database,
|
|
42546
43237
|
DatabaseResult,
|
|
42547
43238
|
DatabaseUrl,
|
|
@@ -42557,6 +43248,7 @@ export {
|
|
|
42557
43248
|
NOT_REQUIRED_ON_ADAPTER,
|
|
42558
43249
|
ObjectId,
|
|
42559
43250
|
OdbcAdapter,
|
|
43251
|
+
Point,
|
|
42560
43252
|
PostgresAdapter,
|
|
42561
43253
|
QueryBuilder,
|
|
42562
43254
|
QueryCache,
|
|
@@ -42569,6 +43261,7 @@ export {
|
|
|
42569
43261
|
S3Storage,
|
|
42570
43262
|
SQLTranslator,
|
|
42571
43263
|
SQLiteAdapter,
|
|
43264
|
+
SpatialNotSupportedError,
|
|
42572
43265
|
SqliteCollection,
|
|
42573
43266
|
SqliteDatabase,
|
|
42574
43267
|
adapterColumns,
|