tina4-nodejs 3.13.103 → 3.13.104

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.
@@ -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;
@@ -23324,6 +23489,14 @@ function resolveSecuritySchemes() {
23324
23489
  const apiKeyIn = ["header", "query", "cookie"].includes(rawIn) ? rawIn : "header";
23325
23490
  schemes.apiKeyAuth = { type: "apiKey", name: apiKeyName, in: apiKeyIn };
23326
23491
  }
23492
+ const ssoIssuer = (process.env.TINA4_SSO_ISSUER ?? "").replace(/\/$/, "");
23493
+ if (ssoIssuer) {
23494
+ schemes.oidc = {
23495
+ type: "openIdConnect",
23496
+ openIdConnectUrl: `${ssoIssuer}/.well-known/openid-configuration`
23497
+ };
23498
+ schemes.ssoSession = { type: "apiKey", in: "cookie", name: "tina4_session" };
23499
+ }
23327
23500
  for (const [name, def] of Object.entries(registeredSchemes)) {
23328
23501
  schemes[name] = def;
23329
23502
  }
@@ -23505,7 +23678,9 @@ function generate(routes, models = []) {
23505
23678
  if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
23506
23679
  }
23507
23680
  } else if (routeRequiresAuth(route, method)) {
23508
- operation.security = sanitizeSecurity([{ [defaultScheme]: [] }], schemes);
23681
+ const requirements = [{ [defaultScheme]: [] }];
23682
+ if (defaultScheme === "bearerAuth" && schemes.ssoSession) requirements.push({ ssoSession: [] });
23683
+ operation.security = sanitizeSecurity(requirements, schemes);
23509
23684
  const responses = operation.responses;
23510
23685
  if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
23511
23686
  }
@@ -23819,6 +23994,298 @@ var init_src = __esm({
23819
23994
  }
23820
23995
  });
23821
23996
 
23997
+ // ../core/src/sso.ts
23998
+ var sso_exports = {};
23999
+ __export(sso_exports, {
24000
+ SSO: () => Sso,
24001
+ Sso: () => Sso,
24002
+ SsoError: () => SsoError
24003
+ });
24004
+ import { createHash as createHash9, randomBytes as randomBytes5, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
24005
+ var SsoError, Sso;
24006
+ var init_sso = __esm({
24007
+ "../core/src/sso.ts"() {
24008
+ "use strict";
24009
+ SsoError = class extends Error {
24010
+ };
24011
+ Sso = class _Sso {
24012
+ static PENDING_KEY = "_tina4_sso_pending";
24013
+ static SESSION_KEY = "_tina4_sso";
24014
+ issuer;
24015
+ clientId;
24016
+ clientSecret;
24017
+ redirectUri;
24018
+ scopes;
24019
+ verify;
24020
+ postLogoutRedirectUri;
24021
+ claimMap;
24022
+ timeout;
24023
+ metadata = {};
24024
+ static mountedRouters = /* @__PURE__ */ new WeakSet();
24025
+ constructor(options = {}) {
24026
+ this.issuer = (options.issuer ?? process.env.TINA4_SSO_ISSUER ?? "").replace(/\/$/, "");
24027
+ this.clientId = options.clientId ?? process.env.TINA4_SSO_CLIENT_ID ?? "";
24028
+ this.clientSecret = options.clientSecret ?? process.env.TINA4_SSO_CLIENT_SECRET;
24029
+ this.redirectUri = options.redirectUri ?? process.env.TINA4_SSO_REDIRECT_URI ?? "";
24030
+ this.scopes = options.scopes ?? this.jsonEnv("TINA4_SSO_SCOPES", ["openid", "profile", "email"]);
24031
+ this.verify = options.verify ?? process.env.TINA4_SSO_VERIFY ?? "introspection";
24032
+ this.postLogoutRedirectUri = options.postLogoutRedirectUri ?? process.env.TINA4_SSO_POST_LOGOUT_REDIRECT_URI;
24033
+ this.claimMap = options.claimMap ?? this.jsonEnv("TINA4_SSO_CLAIM_MAP", {});
24034
+ this.timeout = options.timeout ?? 1e4;
24035
+ this.validateConfig();
24036
+ }
24037
+ static async fromIssuer(options = {}) {
24038
+ const value = new _Sso(options);
24039
+ await value.discover();
24040
+ return value;
24041
+ }
24042
+ static configured() {
24043
+ return ["TINA4_SSO_ISSUER", "TINA4_SSO_CLIENT_ID", "TINA4_SSO_REDIRECT_URI"].every((key) => Boolean(process.env[key]));
24044
+ }
24045
+ jsonEnv(name, fallback) {
24046
+ const raw = process.env[name];
24047
+ if (!raw) return fallback;
24048
+ try {
24049
+ return JSON.parse(raw);
24050
+ } catch {
24051
+ throw new SsoError(`${name} must be valid JSON`);
24052
+ }
24053
+ }
24054
+ static secureUrl(value, name) {
24055
+ let url;
24056
+ try {
24057
+ url = new URL(value);
24058
+ } catch {
24059
+ throw new SsoError(`${name} must be an absolute URL`);
24060
+ }
24061
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
24062
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
24063
+ throw new SsoError(`${name} must use HTTPS except on loopback`);
24064
+ }
24065
+ }
24066
+ validateConfig() {
24067
+ if (!this.issuer || !this.clientId || !this.redirectUri) {
24068
+ throw new SsoError("TINA4_SSO_ISSUER, TINA4_SSO_CLIENT_ID and TINA4_SSO_REDIRECT_URI are required");
24069
+ }
24070
+ _Sso.secureUrl(this.issuer, "issuer");
24071
+ _Sso.secureUrl(this.redirectUri, "redirect URI");
24072
+ if (!["introspection", "jwks"].includes(this.verify)) throw new SsoError("TINA4_SSO_VERIFY must be introspection or jwks");
24073
+ if (this.verify === "jwks") throw new SsoError("jwks verification requires an installed cryptography capability");
24074
+ if (this.verify === "introspection" && !this.clientSecret) throw new SsoError("introspection verification requires TINA4_SSO_CLIENT_SECRET");
24075
+ if (!Array.isArray(this.scopes) || !this.scopes.includes("openid")) throw new SsoError("TINA4_SSO_SCOPES must be a list containing openid");
24076
+ }
24077
+ async requestJson(url, form, bearer, basic = false) {
24078
+ const headers = { Accept: "application/json" };
24079
+ let body;
24080
+ if (form) {
24081
+ const parameters = new URLSearchParams();
24082
+ for (const [key, value] of Object.entries(form)) parameters.set(key, String(value));
24083
+ body = parameters.toString();
24084
+ headers["Content-Type"] = "application/x-www-form-urlencoded";
24085
+ }
24086
+ if (bearer) headers.Authorization = `Bearer ${bearer}`;
24087
+ if (basic) headers.Authorization = `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString("base64")}`;
24088
+ const controller = new AbortController();
24089
+ const timer = setTimeout(() => controller.abort(), this.timeout);
24090
+ try {
24091
+ const response = await fetch(url, { method: form ? "POST" : "GET", headers, body, signal: controller.signal });
24092
+ if (!response.ok) throw new SsoError("OIDC provider request failed");
24093
+ const result = await response.json();
24094
+ if (!result || typeof result !== "object" || Array.isArray(result)) throw new SsoError("OIDC provider returned a non-object response");
24095
+ return result;
24096
+ } catch (error) {
24097
+ if (error instanceof SsoError) throw error;
24098
+ throw new SsoError("OIDC provider request failed");
24099
+ } finally {
24100
+ clearTimeout(timer);
24101
+ }
24102
+ }
24103
+ async discover(force = false) {
24104
+ if (Object.keys(this.metadata).length && !force) return { ...this.metadata };
24105
+ const result = await this.requestJson(`${this.issuer}/.well-known/openid-configuration`);
24106
+ if (result.issuer !== this.issuer) throw new SsoError("OIDC discovery issuer does not exactly match configuration");
24107
+ const required = ["authorization_endpoint", "token_endpoint"];
24108
+ if (this.verify === "introspection") required.push("introspection_endpoint");
24109
+ for (const key of required) {
24110
+ if (!result[key]) throw new SsoError(`OIDC discovery is missing ${key}`);
24111
+ _Sso.secureUrl(result[key], key);
24112
+ }
24113
+ this.metadata = result;
24114
+ return { ...result };
24115
+ }
24116
+ static safeReturn(value) {
24117
+ if (!value || !value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return "/";
24118
+ return [...value].some((char) => char.charCodeAt(0) < 32) ? "/" : value;
24119
+ }
24120
+ session(value) {
24121
+ return value?.session ?? value;
24122
+ }
24123
+ async login(requestOrSession, returnTo = "/") {
24124
+ const session = this.session(requestOrSession);
24125
+ if (!session) throw new SsoError("SSO login requires a Tina4 Session");
24126
+ const state = randomBytes5(32).toString("base64url");
24127
+ const nonce = randomBytes5(32).toString("base64url");
24128
+ const verifier = randomBytes5(64).toString("base64url");
24129
+ const challenge = createHash9("sha256").update(verifier).digest("base64url");
24130
+ session.set(_Sso.PENDING_KEY, { state, nonce, verifier, return_to: _Sso.safeReturn(returnTo), created_at: Math.floor(Date.now() / 1e3) });
24131
+ const metadata = await this.discover();
24132
+ const query = new URLSearchParams({
24133
+ client_id: this.clientId,
24134
+ redirect_uri: this.redirectUri,
24135
+ response_type: "code",
24136
+ scope: this.scopes.join(" "),
24137
+ state,
24138
+ nonce,
24139
+ code_challenge: challenge,
24140
+ code_challenge_method: "S256"
24141
+ });
24142
+ return `${metadata.authorization_endpoint}?${query}`;
24143
+ }
24144
+ static equal(left, right) {
24145
+ const a = Buffer.from(String(left ?? ""));
24146
+ const b = Buffer.from(String(right ?? ""));
24147
+ return a.length === b.length && timingSafeEqual3(a, b);
24148
+ }
24149
+ static jwtPayload(token) {
24150
+ try {
24151
+ return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString());
24152
+ } catch {
24153
+ throw new SsoError("provider returned an invalid ID token");
24154
+ }
24155
+ }
24156
+ async introspect(accessToken) {
24157
+ const metadata = await this.discover();
24158
+ const result = await this.requestJson(metadata.introspection_endpoint, { token: accessToken, token_type_hint: "access_token" }, void 0, true);
24159
+ if (result.active !== true || result.iss !== this.issuer) throw new SsoError("OIDC access token is inactive or has the wrong issuer");
24160
+ const audience = result.aud ?? result.client_id;
24161
+ const valid = (Array.isArray(audience) ? audience.includes(this.clientId) : audience === this.clientId) || result.client_id === this.clientId;
24162
+ if (!valid) throw new SsoError("OIDC token audience mismatch");
24163
+ return result;
24164
+ }
24165
+ claim(claims, configured, fallback) {
24166
+ let value = claims;
24167
+ for (const part of (configured ?? fallback).split(".")) value = value && typeof value === "object" ? value[part] : void 0;
24168
+ return value;
24169
+ }
24170
+ normalize(claims) {
24171
+ const subject = this.claim(claims, this.claimMap.subject, "sub");
24172
+ const issuer = this.claim(claims, this.claimMap.issuer, "iss") ?? this.issuer;
24173
+ if (!subject || issuer !== this.issuer) throw new SsoError("OIDC identity is missing a valid issuer or subject");
24174
+ const roles = [...this.claim(claims, this.claimMap.roles, "realm_access.roles") ?? [], ...claims.resource_access?.[this.clientId]?.roles ?? []];
24175
+ const groups = this.claim(claims, this.claimMap.groups, "groups") ?? [];
24176
+ return {
24177
+ issuer,
24178
+ subject,
24179
+ username: this.claim(claims, this.claimMap.username, "preferred_username") ?? null,
24180
+ email: this.claim(claims, this.claimMap.email, "email") ?? null,
24181
+ name: this.claim(claims, this.claimMap.name, "name") ?? null,
24182
+ roles: [...new Set(roles.map(String))].sort(),
24183
+ groups: [...new Set(groups.map(String))].sort()
24184
+ };
24185
+ }
24186
+ async callback(requestOrSession, query) {
24187
+ const session = this.session(requestOrSession);
24188
+ const values = query ?? requestOrSession?.query ?? {};
24189
+ const pending = session?.get(_Sso.PENDING_KEY);
24190
+ session?.delete(_Sso.PENDING_KEY);
24191
+ if (!pending || !values.code || !_Sso.equal(values.state, pending.state)) throw new SsoError("OIDC callback state is invalid or already consumed");
24192
+ if (Math.floor(Date.now() / 1e3) - Number(pending.created_at ?? 0) > 600) throw new SsoError("OIDC callback state has expired");
24193
+ const metadata = await this.discover();
24194
+ const tokens = await this.requestJson(metadata.token_endpoint, {
24195
+ grant_type: "authorization_code",
24196
+ code: values.code,
24197
+ redirect_uri: this.redirectUri,
24198
+ client_id: this.clientId,
24199
+ code_verifier: pending.verifier
24200
+ }, void 0, Boolean(this.clientSecret));
24201
+ if (!tokens.access_token || !tokens.id_token) throw new SsoError("OIDC token response is incomplete");
24202
+ if (this.verify === "jwks") throw new SsoError("JWKS verification requires an installed cryptography capability");
24203
+ const claims = await this.introspect(tokens.access_token);
24204
+ if (!_Sso.equal(_Sso.jwtPayload(tokens.id_token).nonce, pending.nonce)) throw new SsoError("OIDC ID token nonce mismatch");
24205
+ if (metadata.userinfo_endpoint) Object.assign(claims, await this.requestJson(metadata.userinfo_endpoint, void 0, tokens.access_token));
24206
+ const identity = this.normalize(claims);
24207
+ session.regenerate();
24208
+ session.set(_Sso.SESSION_KEY, {
24209
+ version: 1,
24210
+ identity,
24211
+ access_token: tokens.access_token,
24212
+ refresh_token: tokens.refresh_token,
24213
+ id_token: tokens.id_token,
24214
+ expires_at: Math.floor(Date.now() / 1e3) + Number(tokens.expires_in ?? 0)
24215
+ });
24216
+ return { identity, return_to: _Sso.safeReturn(pending.return_to) };
24217
+ }
24218
+ identity(requestOrSession) {
24219
+ const stored = this.session(requestOrSession)?.get(_Sso.SESSION_KEY);
24220
+ const identity = stored?.identity ?? null;
24221
+ if (identity && requestOrSession?.session) requestOrSession.user = identity;
24222
+ return identity;
24223
+ }
24224
+ async refresh(requestOrSession) {
24225
+ const session = this.session(requestOrSession);
24226
+ const stored = session?.get(_Sso.SESSION_KEY);
24227
+ if (!stored?.refresh_token) {
24228
+ session?.delete(_Sso.SESSION_KEY);
24229
+ throw new SsoError("OIDC session cannot be refreshed");
24230
+ }
24231
+ try {
24232
+ const metadata = await this.discover();
24233
+ const tokens = await this.requestJson(metadata.token_endpoint, {
24234
+ grant_type: "refresh_token",
24235
+ refresh_token: stored.refresh_token,
24236
+ client_id: this.clientId
24237
+ }, void 0, Boolean(this.clientSecret));
24238
+ const claims = await this.introspect(tokens.access_token);
24239
+ if (metadata.userinfo_endpoint) Object.assign(claims, await this.requestJson(metadata.userinfo_endpoint, void 0, tokens.access_token));
24240
+ const identity = this.normalize(claims);
24241
+ session.set(_Sso.SESSION_KEY, {
24242
+ ...stored,
24243
+ identity,
24244
+ access_token: tokens.access_token,
24245
+ refresh_token: tokens.refresh_token ?? stored.refresh_token,
24246
+ id_token: tokens.id_token ?? stored.id_token,
24247
+ expires_at: Math.floor(Date.now() / 1e3) + Number(tokens.expires_in ?? 0)
24248
+ });
24249
+ return identity;
24250
+ } catch (error) {
24251
+ session?.delete(_Sso.SESSION_KEY);
24252
+ throw error;
24253
+ }
24254
+ }
24255
+ async logout(requestOrSession, returnTo = "/") {
24256
+ const session = this.session(requestOrSession);
24257
+ const stored = session?.get(_Sso.SESSION_KEY);
24258
+ session?.destroy();
24259
+ const endpoint = (await this.discover()).end_session_endpoint;
24260
+ const target = this.postLogoutRedirectUri ?? _Sso.safeReturn(returnTo);
24261
+ if (!endpoint) return target;
24262
+ const params = new URLSearchParams({ post_logout_redirect_uri: target, client_id: this.clientId });
24263
+ if (stored?.id_token) params.set("id_token_hint", stored.id_token);
24264
+ return `${endpoint}?${params}`;
24265
+ }
24266
+ static async mountConfigured(router) {
24267
+ if (_Sso.mountedRouters.has(router) || !_Sso.configured()) return false;
24268
+ const owned = /* @__PURE__ */ new Set(["GET /auth/login", "GET /auth/callback", "POST /auth/logout"]);
24269
+ const collisions = router.getRoutes().map((route) => `${route.method} ${route.pattern}`).filter((route) => owned.has(route));
24270
+ if (collisions.length) throw new SsoError(`SSO route collision: ${collisions.join(", ")}`);
24271
+ const sso = await _Sso.fromIssuer();
24272
+ router.get("/auth/login", async (req2, res) => res.redirect(await sso.login(req2, req2.query?.return_to ?? "/")));
24273
+ router.get("/auth/callback", async (req2, res) => {
24274
+ try {
24275
+ return res.redirect((await sso.callback(req2)).return_to);
24276
+ } catch (error) {
24277
+ const message = error instanceof SsoError ? error.message : "OIDC callback failed";
24278
+ return res.error("SSO_CALLBACK_FAILED", message, 400);
24279
+ }
24280
+ });
24281
+ router.post("/auth/logout", async (req2, res) => res.redirect(await sso.logout(req2, req2.query?.return_to ?? "/")));
24282
+ _Sso.mountedRouters.add(router);
24283
+ return true;
24284
+ }
24285
+ };
24286
+ }
24287
+ });
24288
+
23822
24289
  // ../core/src/docsAutoDiscovery.ts
23823
24290
  var docsAutoDiscovery_exports = {};
23824
24291
  __export(docsAutoDiscovery_exports, {
@@ -23888,7 +24355,7 @@ var init_docsAutoDiscovery = __esm({
23888
24355
 
23889
24356
  // ../core/src/server.ts
23890
24357
  import { createServer as createServer2 } from "node:http";
23891
- import { randomBytes as randomBytes5 } from "node:crypto";
24358
+ import { randomBytes as randomBytes6 } from "node:crypto";
23892
24359
  import { resolve as resolve14, dirname as dirname11, join as join24, relative as relative8 } from "node:path";
23893
24360
  import { existsSync as existsSync22, readdirSync as readdirSync14, readFileSync as readFileSync21, statSync as statSync15 } from "node:fs";
23894
24361
  import { isatty } from "node:tty";
@@ -24528,7 +24995,7 @@ async function renderDispatchError(err, req2, res, templatesDir) {
24528
24995
  }
24529
24996
  }
24530
24997
  }
24531
- const requestId = Log.getRequestId() ?? randomBytes5(4).toString("hex");
24998
+ const requestId = Log.getRequestId() ?? randomBytes6(4).toString("hex");
24532
24999
  if (wantsJson(req2)) {
24533
25000
  const body = negotiatedErrorBody(500, "Internal Server Error", requestId);
24534
25001
  res.raw.writeHead(500, { "Content-Type": "application/json" });
@@ -24599,7 +25066,7 @@ function serveStaticAsset(ctx) {
24599
25066
  return false;
24600
25067
  }
24601
25068
  async function serveNotFound(ctx) {
24602
- const requestId = Log.getRequestId() ?? randomBytes5(4).toString("hex");
25069
+ const requestId = Log.getRequestId() ?? randomBytes6(4).toString("hex");
24603
25070
  if (wantsJson(ctx.req)) {
24604
25071
  const body = negotiatedErrorBody(404, "Not Found", requestId);
24605
25072
  ctx.res.raw.writeHead(404, httpReason(404), { "Content-Type": "application/json" });
@@ -24719,7 +25186,7 @@ async function dispatchInner(ctx, rawReq, rawRes, requestId) {
24719
25186
  }
24720
25187
  }
24721
25188
  async function runDispatch(ctx, rawReq, rawRes) {
24722
- const requestId = Log.sanitizeRequestId(rawReq.headers["x-request-id"]) ?? randomBytes5(4).toString("hex");
25189
+ const requestId = Log.sanitizeRequestId(rawReq.headers["x-request-id"]) ?? randomBytes6(4).toString("hex");
24723
25190
  if (!rawRes.headersSent) rawRes.setHeader("x-request-id", requestId);
24724
25191
  return Log.runWithRequestId(requestId, () => dispatchInner(ctx, rawReq, rawRes, requestId));
24725
25192
  }
@@ -24853,6 +25320,8 @@ ${reset2}
24853
25320
  console.log(`
24854
25321
  No routes directory found at ${routesDir}`);
24855
25322
  }
25323
+ const { Sso: Sso2 } = await Promise.resolve().then(() => (init_sso(), sso_exports));
25324
+ await Sso2.mountConfigured(router);
24856
25325
  if (attachCsrfFromEnv()) {
24857
25326
  console.log(`
24858
25327
  \x1B[36mCSRF\x1B[0m protection enabled (TINA4_CSRF)`);
@@ -25845,7 +26314,7 @@ var init_mqttMessage = __esm({
25845
26314
  // ../core/src/mqtt.ts
25846
26315
  import net2 from "node:net";
25847
26316
  import tls from "node:tls";
25848
- import { randomBytes as randomBytes6 } from "node:crypto";
26317
+ import { randomBytes as randomBytes7 } from "node:crypto";
25849
26318
  import { existsSync as existsSync24, readFileSync as readFileSync22 } from "node:fs";
25850
26319
  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
26320
  var init_mqtt = __esm({
@@ -25933,7 +26402,7 @@ var init_mqtt = __esm({
25933
26402
  this.caFile = options.caFile ?? (Env.str("TINA4_MQTT_CA_FILE") || null);
25934
26403
  this.tlsVerify = options.tlsVerify ?? Env.bool("TINA4_MQTT_TLS_VERIFY", true);
25935
26404
  let cid = options.clientId ?? (Env.str("TINA4_MQTT_CLIENT_ID") || null);
25936
- if (cid === null || cid === "") cid = "tina4-" + randomBytes6(8).toString("hex");
26405
+ if (cid === null || cid === "") cid = "tina4-" + randomBytes7(8).toString("hex");
25937
26406
  this.clientId = cid;
25938
26407
  this.keepalive = options.keepalive ?? Env.int("TINA4_MQTT_KEEPALIVE", DEFAULT_KEEPALIVE);
25939
26408
  this.cleanSession = options.cleanSession ?? true;
@@ -26863,7 +27332,7 @@ var init_service = __esm({
26863
27332
  import http from "node:http";
26864
27333
  import https from "node:https";
26865
27334
  import { URL as URL2 } from "node:url";
26866
- import { randomBytes as randomBytes7 } from "node:crypto";
27335
+ import { randomBytes as randomBytes8 } from "node:crypto";
26867
27336
  import { promises as fsp, createWriteStream } from "node:fs";
26868
27337
  import { basename as basename5 } from "node:path";
26869
27338
  import { pipeline } from "node:stream/promises";
@@ -27161,7 +27630,7 @@ var init_api = __esm({
27161
27630
  return { http_code: null, body: null, headers: {}, error: "upload requires filePath or fileBytes" };
27162
27631
  }
27163
27632
  const partContentType = guessContentType(uploadName);
27164
- const boundary = "----Tina4Boundary" + randomBytes7(16).toString("hex");
27633
+ const boundary = "----Tina4Boundary" + randomBytes8(16).toString("hex");
27165
27634
  const bodyBuffer = buildMultipartBody(boundary, fieldName, uploadName, content, partContentType, extraFields);
27166
27635
  const contentType = `multipart/form-data; boundary=${boundary}`;
27167
27636
  return this.execute("POST", this.buildUrl(path8), bodyBuffer, contentType, headers);
@@ -31993,10 +32462,13 @@ __export(src_exports2, {
31993
32462
  RouteGroup: () => RouteGroup,
31994
32463
  RouteRef: () => RouteRef,
31995
32464
  Router: () => Router,
32465
+ SSO: () => Sso,
31996
32466
  SafeString: () => SafeString2,
31997
32467
  SecurityHeadersMiddleware: () => SecurityHeadersMiddleware,
31998
32468
  ServiceRunner: () => ServiceRunner,
31999
32469
  Session: () => Session,
32470
+ Sso: () => Sso,
32471
+ SsoError: () => SsoError,
32000
32472
  TAKEOVER_KILLED: () => TAKEOVER_KILLED,
32001
32473
  TAKEOVER_NOTHING: () => TAKEOVER_NOTHING,
32002
32474
  TAKEOVER_REFUSALS: () => TAKEOVER_REFUSALS,
@@ -32233,6 +32705,7 @@ var init_src2 = __esm({
32233
32705
  init_htmlElement();
32234
32706
  init_errorOverlay();
32235
32707
  init_ai();
32708
+ init_sso();
32236
32709
  init_aiClient();
32237
32710
  init_liteBackend();
32238
32711
  init_rabbitmqBackend();
@@ -33331,6 +33804,8 @@ function fieldTypeToPostgres(def) {
33331
33804
  return "TEXT";
33332
33805
  case "json":
33333
33806
  return "JSONB";
33807
+ case "point":
33808
+ return SQLTranslator.pointColumnType("postgres", def.srid ?? 4326);
33334
33809
  case "string":
33335
33810
  return def.maxLength ? `VARCHAR(${def.maxLength})` : "VARCHAR(255)";
33336
33811
  default:
@@ -38954,10 +39429,13 @@ var init_queryBuilder = __esm({
38954
39429
  "use strict";
38955
39430
  init_database();
38956
39431
  init_databaseResult();
39432
+ init_point();
39433
+ init_sqlTranslator();
38957
39434
  QueryBuilder = class _QueryBuilder {
38958
39435
  table;
38959
39436
  db;
38960
39437
  columns = ["*"];
39438
+ selectParams = [];
38961
39439
  wheres = [];
38962
39440
  params = [];
38963
39441
  joinClauses = [];
@@ -38965,14 +39443,17 @@ var init_queryBuilder = __esm({
38965
39443
  havings = [];
38966
39444
  havingParams = [];
38967
39445
  orderByCols = [];
39446
+ orderByParams = [];
39447
+ primaryKey;
38968
39448
  limitVal;
38969
39449
  offsetVal;
38970
39450
  /**
38971
39451
  * Private constructor — use static factory methods.
38972
39452
  */
38973
- constructor(table2, db) {
39453
+ constructor(table2, db, primaryKey) {
38974
39454
  this.table = table2;
38975
39455
  this.db = db;
39456
+ this.primaryKey = primaryKey;
38976
39457
  }
38977
39458
  /**
38978
39459
  * Create a QueryBuilder for a table.
@@ -38981,8 +39462,8 @@ var init_queryBuilder = __esm({
38981
39462
  * @param db - Optional database adapter.
38982
39463
  * @returns A new QueryBuilder instance.
38983
39464
  */
38984
- static fromTable(tableName, db) {
38985
- return new _QueryBuilder(tableName, db);
39465
+ static fromTable(tableName, db, primaryKey) {
39466
+ return new _QueryBuilder(tableName, db, primaryKey);
38986
39467
  }
38987
39468
  /**
38988
39469
  * Set the columns to select.
@@ -38993,6 +39474,7 @@ var init_queryBuilder = __esm({
38993
39474
  select(...cols) {
38994
39475
  if (cols.length > 0) {
38995
39476
  this.columns = cols;
39477
+ this.selectParams = [];
38996
39478
  }
38997
39479
  return this;
38998
39480
  }
@@ -39074,6 +39556,41 @@ var init_queryBuilder = __esm({
39074
39556
  this.orderByCols.push(expression);
39075
39557
  return this;
39076
39558
  }
39559
+ withinDistance(column2, pointValue, radiusMetres, srid = DEFAULT_SRID) {
39560
+ const radius = Number(radiusMetres);
39561
+ if (!Number.isFinite(radius) || radius < 0) throw new RangeError("Spatial radius must be finite and greater than or equal to zero");
39562
+ const point = Point.parse(pointValue, srid);
39563
+ return this.where(SQLTranslator.withinDistance(this.engine(), column2, point.srid), [point.lon, point.lat, radius]);
39564
+ }
39565
+ intersects(column2, geometry, srid = DEFAULT_SRID) {
39566
+ const [bound, form] = Point.geometryBinding(geometry, srid);
39567
+ return this.where(SQLTranslator.intersects(this.engine(), column2, form, srid), [bound]);
39568
+ }
39569
+ bbox(column2, minLon, minLat, maxLon, maxLat, srid = DEFAULT_SRID) {
39570
+ const values = [minLon, minLat, maxLon, maxLat].map(Number);
39571
+ if (!values.every(Number.isFinite)) throw new TypeError("Bounding-box coordinates must be finite numbers");
39572
+ const [west, south, east, north] = values;
39573
+ new Point(west, south, srid);
39574
+ new Point(east, north, srid);
39575
+ if (west > east || south > north) throw new RangeError("Bounding box must be ordered west, south, east, north");
39576
+ return this.where(SQLTranslator.bbox(this.engine(), column2, srid), values);
39577
+ }
39578
+ selectDistance(column2, pointValue, alias = "distance", srid = DEFAULT_SRID) {
39579
+ const point = Point.parse(pointValue, srid);
39580
+ this.columns.push(SQLTranslator.distanceAs(this.engine(), column2, alias, point.srid));
39581
+ this.selectParams.push(point.lon, point.lat);
39582
+ return this;
39583
+ }
39584
+ orderByDistance(column2, pointValue, direction = "ASC", srid = DEFAULT_SRID) {
39585
+ const order = direction.toUpperCase();
39586
+ if (order !== "ASC" && order !== "DESC") throw new TypeError("Distance order direction must be ASC or DESC");
39587
+ if (!this.primaryKey) throw new Error("Stable spatial ordering needs a primary key; use BaseModel.query() or pass one to fromTable()");
39588
+ const point = Point.parse(pointValue, srid);
39589
+ this.orderByCols.push(`${SQLTranslator.distance(this.engine(), column2, point.srid)} ${order}`);
39590
+ this.orderByParams.push(point.lon, point.lat);
39591
+ this.orderByCols.push(`${SQLTranslator.spatialIdentifier(this.primaryKey, "primary key")} ASC`);
39592
+ return this;
39593
+ }
39077
39594
  /**
39078
39595
  * Set LIMIT and optional OFFSET.
39079
39596
  *
@@ -39139,7 +39656,7 @@ var init_queryBuilder = __esm({
39139
39656
  async get() {
39140
39657
  this.ensureDb();
39141
39658
  const sql = this.toSql();
39142
- const allParams = [...this.params, ...this.havingParams];
39659
+ const allParams = [...this.selectParams, ...this.params, ...this.havingParams, ...this.orderByParams];
39143
39660
  const queryParams = allParams.length > 0 ? allParams : void 0;
39144
39661
  const rows = await adapterFetch(
39145
39662
  this.db,
@@ -39167,7 +39684,7 @@ var init_queryBuilder = __esm({
39167
39684
  async first() {
39168
39685
  this.ensureDb();
39169
39686
  const sql = this.toSql();
39170
- const allParams = [...this.params, ...this.havingParams];
39687
+ const allParams = [...this.selectParams, ...this.params, ...this.havingParams, ...this.orderByParams];
39171
39688
  return adapterFetchOne(
39172
39689
  this.db,
39173
39690
  sql,
@@ -39182,9 +39699,18 @@ var init_queryBuilder = __esm({
39182
39699
  async count() {
39183
39700
  this.ensureDb();
39184
39701
  const original = this.columns;
39702
+ const originalSelectParams = this.selectParams;
39703
+ const originalOrder = this.orderByCols;
39704
+ const originalOrderParams = this.orderByParams;
39185
39705
  this.columns = ["COUNT(*) as cnt"];
39706
+ this.selectParams = [];
39707
+ this.orderByCols = [];
39708
+ this.orderByParams = [];
39186
39709
  const sql = this.toSql();
39187
39710
  this.columns = original;
39711
+ this.selectParams = originalSelectParams;
39712
+ this.orderByCols = originalOrder;
39713
+ this.orderByParams = originalOrderParams;
39188
39714
  const allParams = [...this.params, ...this.havingParams];
39189
39715
  const row = await adapterFetchOne(
39190
39716
  this.db,
@@ -39369,6 +39895,10 @@ var init_queryBuilder = __esm({
39369
39895
  }
39370
39896
  }
39371
39897
  }
39898
+ engine() {
39899
+ this.ensureDb();
39900
+ return this.db.getDatabaseType();
39901
+ }
39372
39902
  };
39373
39903
  }
39374
39904
  });
@@ -39392,6 +39922,11 @@ function toDbFieldValue(def, value) {
39392
39922
  if (def?.type === "json" && value !== null && value !== void 0 && typeof value !== "string") {
39393
39923
  return JSON.stringify(value);
39394
39924
  }
39925
+ if (def?.type === "point" && value !== null && value !== void 0) {
39926
+ const point = Point.parse(value, def.srid ?? DEFAULT_SRID);
39927
+ if (point.srid !== (def.srid ?? DEFAULT_SRID)) throw new TypeError(`Point field expects SRID ${def.srid ?? DEFAULT_SRID}; received ${point.srid}`);
39928
+ return point.ewkt;
39929
+ }
39395
39930
  return value;
39396
39931
  }
39397
39932
  function fromDbFieldValue(def, value) {
@@ -39402,6 +39937,11 @@ function fromDbFieldValue(def, value) {
39402
39937
  return value;
39403
39938
  }
39404
39939
  }
39940
+ if (def?.type === "point" && value !== null && value !== void 0) {
39941
+ const point = Point.parse(value, def.srid ?? DEFAULT_SRID);
39942
+ if (point.srid !== (def.srid ?? DEFAULT_SRID)) throw new TypeError(`Point field expects SRID ${def.srid ?? DEFAULT_SRID}; received ${point.srid}`);
39943
+ return point;
39944
+ }
39405
39945
  return value;
39406
39946
  }
39407
39947
  function _pluralRelKeys() {
@@ -39437,6 +39977,7 @@ var init_baseModel = __esm({
39437
39977
  init_sqlite();
39438
39978
  init_sqlTranslator();
39439
39979
  init_src2();
39980
+ init_point();
39440
39981
  _fkRegistry = /* @__PURE__ */ new Map();
39441
39982
  EAGER_IN_CHUNK = 500;
39442
39983
  modelQueryCache = new QueryCache({ defaultTtl: 0, maxSize: 500 });
@@ -39492,7 +40033,9 @@ var init_baseModel = __esm({
39492
40033
  for (const [name, def] of Object.entries(fields0)) {
39493
40034
  if (def.default === void 0) continue;
39494
40035
  let dv = typeof def.default === "function" ? def.default() : def.default;
39495
- if (dv !== null && typeof dv === "object") dv = structuredClone(dv);
40036
+ if (def.type === "point" && dv !== null && dv !== void 0) {
40037
+ dv = fromDbFieldValue(def, dv);
40038
+ } else if (dv !== null && typeof dv === "object") dv = structuredClone(dv);
39496
40039
  this[name] = dv;
39497
40040
  }
39498
40041
  if (data) {
@@ -39594,7 +40137,7 @@ var init_baseModel = __esm({
39594
40137
  * @returns A QueryBuilder instance bound to this model's table and database.
39595
40138
  */
39596
40139
  static query() {
39597
- return QueryBuilder.fromTable(this.tableName, this.getDb());
40140
+ return QueryBuilder.fromTable(this.tableName, this.getDb(), this.getPkColumn());
39598
40141
  }
39599
40142
  /**
39600
40143
  * Get the database adapter for this model.
@@ -40047,7 +40590,7 @@ var init_baseModel = __esm({
40047
40590
  for (const key of Object.keys(ModelClass.fields)) {
40048
40591
  if (this[key] !== void 0) {
40049
40592
  const outKey = case_ === "snake" ? ModelClass.fieldMapping[key] ?? key : key;
40050
- result[outKey] = this[key];
40593
+ result[outKey] = this[key] instanceof Point ? this[key].geojson : this[key];
40051
40594
  }
40052
40595
  }
40053
40596
  if (ModelClass.softDelete && this.is_deleted !== void 0) {
@@ -40107,6 +40650,19 @@ var init_baseModel = __esm({
40107
40650
  }
40108
40651
  return result;
40109
40652
  }
40653
+ toFeature(geometryField, include) {
40654
+ const ModelClass = this.constructor;
40655
+ const pointFields = Object.entries(ModelClass.fields).filter(([, def]) => def.type === "point").map(([name]) => name);
40656
+ const field = geometryField ?? pointFields[0];
40657
+ if (!field || !pointFields.includes(field)) throw new Error("toFeature() needs a declared point field");
40658
+ const properties = this.toDict(include, "camel");
40659
+ const geometry = properties[field] ?? null;
40660
+ delete properties[field];
40661
+ return { type: "Feature", geometry, properties };
40662
+ }
40663
+ static featureCollection(models, geometryField, include) {
40664
+ return { type: "FeatureCollection", features: models.map((model) => model.toFeature(geometryField, include)) };
40665
+ }
40110
40666
  /**
40111
40667
  * Convert to an associative object (alias for toDict).
40112
40668
  */
@@ -40157,7 +40713,10 @@ var init_baseModel = __esm({
40157
40713
  */
40158
40714
  static async createTable() {
40159
40715
  const db = this.getDb();
40160
- if (await adapterTableExists(db, this.tableName)) return true;
40716
+ const pointFields = Object.entries(this.fields).filter(([, def]) => def.type === "point");
40717
+ const engine = db.getDatabaseType();
40718
+ if (pointFields.length > 0) SQLTranslator.requireSpatial(engine, "PointField");
40719
+ if (await adapterTableExists(db, this.tableName)) return this.createSpatialIndexes(db, pointFields);
40161
40720
  if (typeof db.createTable === "function" || typeof db.createTableAsync === "function") {
40162
40721
  const mappedFields = {};
40163
40722
  for (const [fieldName, def] of Object.entries(this.fields)) {
@@ -40173,7 +40732,7 @@ var init_baseModel = __esm({
40173
40732
  mappedFields["is_deleted"] = { type: "integer", default: 0 };
40174
40733
  }
40175
40734
  await adapterCreateTable(db, this.tableName, mappedFields);
40176
- return true;
40735
+ return this.createSpatialIndexes(db, pointFields);
40177
40736
  }
40178
40737
  const typeMap = {
40179
40738
  integer: "INTEGER",
@@ -40220,6 +40779,14 @@ var init_baseModel = __esm({
40220
40779
  }
40221
40780
  return true;
40222
40781
  }
40782
+ static async createSpatialIndexes(db, fields) {
40783
+ for (const [fieldName, def] of fields) {
40784
+ SQLTranslator.pointColumnType(db.getDatabaseType(), def.srid ?? DEFAULT_SRID);
40785
+ if (def.spatialIndex === false) continue;
40786
+ await adapterExecute(db, SQLTranslator.spatialIndex(db.getDatabaseType(), this.tableName, this.getDbColumn(fieldName)));
40787
+ }
40788
+ return true;
40789
+ }
40223
40790
  /**
40224
40791
  * Find a record by primary key or throw an error if not found.
40225
40792
  */
@@ -41159,7 +41726,7 @@ var init_seeder = __esm({
41159
41726
 
41160
41727
  // src/docstore.ts
41161
41728
  import { DatabaseSync as DatabaseSync4 } from "node:sqlite";
41162
- import { randomBytes as randomBytes8 } from "node:crypto";
41729
+ import { randomBytes as randomBytes9 } from "node:crypto";
41163
41730
  import { mkdirSync as mkdirSync19 } from "node:fs";
41164
41731
  import { dirname as dirname14, isAbsolute as isAbsolute6, join as join31 } from "node:path";
41165
41732
  function iso(d) {
@@ -41511,8 +42078,8 @@ var init_docstore = __esm({
41511
42078
  OID_RE = /^[0-9a-fA-F]{24}$/;
41512
42079
  ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/;
41513
42080
  ObjectId = class _ObjectId {
41514
- static _counter = randomBytes8(3).readUIntBE(0, 3);
41515
- static _process = randomBytes8(5);
42081
+ static _counter = randomBytes9(3).readUIntBE(0, 3);
42082
+ static _process = randomBytes9(5);
41516
42083
  _bytes;
41517
42084
  constructor(oid) {
41518
42085
  if (oid === void 0 || oid === null) {
@@ -41940,7 +42507,7 @@ var init_attachment = __esm({
41940
42507
  });
41941
42508
 
41942
42509
  // src/realtime/storage.ts
41943
- import { randomBytes as randomBytes9 } from "node:crypto";
42510
+ import { randomBytes as randomBytes10 } from "node:crypto";
41944
42511
  import { mkdirSync as mkdirSync20, readFileSync as readFileSync26, writeFileSync as writeFileSync17, unlinkSync as unlinkSync8, statSync as statSync18 } from "node:fs";
41945
42512
  import { resolve as resolve19, sep as sep5 } from "node:path";
41946
42513
  import { createRequire as createRequire8 } from "node:module";
@@ -41951,7 +42518,7 @@ function storageKey(filename = "") {
41951
42518
  const clean = raw.replace(UNSAFE, "").slice(0, 12);
41952
42519
  if (clean) ext = `.${clean}`;
41953
42520
  }
41954
- return `${randomBytes9(16).toString("hex")}${ext}`;
42521
+ return `${randomBytes10(16).toString("hex")}${ext}`;
41955
42522
  }
41956
42523
  function selectStorage(storage) {
41957
42524
  if (storage) return storage;
@@ -42399,6 +42966,7 @@ __export(index_exports, {
42399
42966
  CachedDatabaseAdapter: () => CachedDatabaseAdapter,
42400
42967
  Cursor: () => Cursor,
42401
42968
  DEFAULT_DATABASE_CONNECT_TIMEOUT_SECONDS: () => DEFAULT_DATABASE_CONNECT_TIMEOUT_SECONDS,
42969
+ DEFAULT_SRID: () => DEFAULT_SRID,
42402
42970
  Database: () => Database,
42403
42971
  DatabaseResult: () => DatabaseResult,
42404
42972
  DatabaseUrl: () => DatabaseUrl,
@@ -42414,6 +42982,7 @@ __export(index_exports, {
42414
42982
  NOT_REQUIRED_ON_ADAPTER: () => NOT_REQUIRED_ON_ADAPTER,
42415
42983
  ObjectId: () => ObjectId,
42416
42984
  OdbcAdapter: () => OdbcAdapter,
42985
+ Point: () => Point,
42417
42986
  PostgresAdapter: () => PostgresAdapter,
42418
42987
  QueryBuilder: () => QueryBuilder,
42419
42988
  QueryCache: () => QueryCache,
@@ -42426,6 +42995,7 @@ __export(index_exports, {
42426
42995
  S3Storage: () => S3Storage,
42427
42996
  SQLTranslator: () => SQLTranslator,
42428
42997
  SQLiteAdapter: () => SQLiteAdapter,
42998
+ SpatialNotSupportedError: () => SpatialNotSupportedError,
42429
42999
  SqliteCollection: () => SqliteCollection,
42430
43000
  SqliteDatabase: () => SqliteDatabase,
42431
43001
  adapterColumns: () => adapterColumns,
@@ -42519,6 +43089,7 @@ var init_index = __esm({
42519
43089
  init_baseModel();
42520
43090
  init_queryBuilder();
42521
43091
  init_sqlTranslator();
43092
+ init_point();
42522
43093
  init_connectTimeout();
42523
43094
  init_cachedDatabase();
42524
43095
  init_fakeData2();
@@ -42542,6 +43113,7 @@ export {
42542
43113
  CachedDatabaseAdapter,
42543
43114
  Cursor,
42544
43115
  DEFAULT_DATABASE_CONNECT_TIMEOUT_SECONDS,
43116
+ DEFAULT_SRID,
42545
43117
  Database,
42546
43118
  DatabaseResult,
42547
43119
  DatabaseUrl,
@@ -42557,6 +43129,7 @@ export {
42557
43129
  NOT_REQUIRED_ON_ADAPTER,
42558
43130
  ObjectId,
42559
43131
  OdbcAdapter,
43132
+ Point,
42560
43133
  PostgresAdapter,
42561
43134
  QueryBuilder,
42562
43135
  QueryCache,
@@ -42569,6 +43142,7 @@ export {
42569
43142
  S3Storage,
42570
43143
  SQLTranslator,
42571
43144
  SQLiteAdapter,
43145
+ SpatialNotSupportedError,
42572
43146
  SqliteCollection,
42573
43147
  SqliteDatabase,
42574
43148
  adapterColumns,