tina4ruby 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6097dca4375329be7c8539cf9e40a8cde39fd9a6041169e58b9edf6bcbcf491a
4
- data.tar.gz: 9d90b71f00d6a59a151a5a3108319aed79faccaac0858f40c3572825765f0acf
3
+ metadata.gz: 4ab6d7ce15da93e814204665a8690be0cc2ac58dc1152fa75f050b91fcb0c4f0
4
+ data.tar.gz: 35fc27eb9d71c17a0049d983d34ed6c1584e014338192e5ace087e5e2bec0746
5
5
  SHA512:
6
- metadata.gz: 74d5064e7aca1cffcdfd0389b6662a4f2d921ed18de43f8d9f0741bffa6a8ceb7d381af281b44b9e114237d7c783068c1adef1b709eb8c36efa017aa6eeb633e
7
- data.tar.gz: 13fd4fa1ea1e667eee942d516cd48c14a2488e686ace7d9597d3affe368d2e7ad8e86772367b8d5ed5b3cc94b9a5631a41d62694abf055999ccbab30c9efad99
6
+ metadata.gz: f2e66595ddcf7eca7d498428149ca38a83caf4871d8d5e29e31ef9d72f7f38f2ed9c3afe638ba2891bf9ffda5b00dbbf54883ddc704bf06787f9f06060b0603b
7
+ data.tar.gz: c45ab7892609d2be7e0b3dc432a8d54bef9dd8281e3594198b9fa93e9170ceeb60372624c69d49978884ef596cb7ff35eeb720216c4d240a176e372ffa33d803
data/CHANGELOG.md CHANGED
@@ -6,6 +6,26 @@ number means the same thing everywhere.
6
6
  **The authoritative release notes for every shipped version live in the documentation:**
7
7
  https://tina4.com/ruby/36-releases
8
8
 
9
+ ## 3.13.105
10
+
11
+ Bug release. Route inspection stops touching the app; Firebird's migration
12
+ ledger tolerates whatever case the driver hands back; PHP loses a colon-in-
13
+ filename that broke Windows checkouts.
14
+
15
+ ### Route inspection scans, never boots
16
+
17
+ - `tina4 routes` now walks canonical route files and never executes the
18
+ application entrypoint or starts the server. Feature 115 / ADR-0058.
19
+ - Fixes the case where `tina4 routes --override` would boot the app on the
20
+ same port and kill whatever process was already holding it (tina4-python
21
+ issue #104).
22
+
23
+ ### Firebird migration ledger is case-agnostic
24
+
25
+ - `tina4_migration` reads and writes work regardless of the case the
26
+ Firebird driver returns for the `migration_name` column.
27
+ - Uses the atomic sequence table pattern already in place for other engines.
28
+
9
29
  ## 3.13.103
10
30
 
11
31
  ### Metrics reports what it can prove
data/lib/tina4/cli.rb CHANGED
@@ -1013,8 +1013,8 @@ module Tina4
1013
1013
 
1014
1014
  def cmd_routes(_argv = nil)
1015
1015
  require_relative "../tina4"
1016
- Tina4.initialize!(Dir.pwd)
1017
- load_routes(Dir.pwd)
1016
+ Tina4::Env.load_env(Dir.pwd)
1017
+ load_routes(Dir.pwd, entrypoints: false)
1018
1018
 
1019
1019
  puts "\nRegistered Routes:"
1020
1020
  puts "-" * 60
@@ -2959,7 +2959,7 @@ module Tina4
2959
2959
 
2960
2960
  # ── shared helpers ────────────────────────────────────────────────────
2961
2961
 
2962
- def load_routes(root_dir)
2962
+ def load_routes(root_dir, entrypoints: true)
2963
2963
  route_dirs = %w[src/routes routes src/api api src/orm orm]
2964
2964
  route_dirs.each do |dir|
2965
2965
  route_dir = File.join(root_dir, dir)
@@ -2967,7 +2967,10 @@ module Tina4
2967
2967
  Dir.glob(File.join(route_dir, "**/*.rb")).sort.each { |f| load f }
2968
2968
  end
2969
2969
 
2970
- # Also load app.rb if it exists
2970
+ return unless entrypoints
2971
+
2972
+ # Runtime and console boot load the application entrypoint. Inspection
2973
+ # commands opt out so they can never start a server or take over a port.
2971
2974
  app_file = File.join(root_dir, "app.rb")
2972
2975
  load app_file if File.exist?(app_file)
2973
2976
 
@@ -108,6 +108,11 @@ module Tina4
108
108
  register_field(name, :json, nullable: nullable, default: default)
109
109
  end
110
110
 
111
+ def point_field(name, srid: Tina4::Point::DEFAULT_SRID, spatial_index: true, nullable: true, default: nil)
112
+ register_field(name, :point, srid: Integer(srid), spatial_index: spatial_index,
113
+ nullable: nullable, default: default)
114
+ end
115
+
111
116
  # Declare a foreign key integer column and auto-wire relationships.
112
117
  #
113
118
  # Automatically:
@@ -230,6 +235,13 @@ module Tina4
230
235
  # __setattr__ + object.__setattr__ default-seeding.
231
236
  attr_reader name
232
237
  define_method("#{name}=") do |value|
238
+ if type == :point && !value.nil?
239
+ point = Tina4::Point.parse(value, srid: options[:srid])
240
+ if point.srid != options[:srid]
241
+ raise ArgumentError, "Point field '#{name}' expects SRID #{options[:srid]}; received #{point.srid}"
242
+ end
243
+ value = point
244
+ end
233
245
  fields = (@assigned_fields ||= [])
234
246
  fields << name unless fields.include?(name)
235
247
  instance_variable_set("@#{name}", value)
data/lib/tina4/orm.rb CHANGED
@@ -245,7 +245,7 @@ module Tina4
245
245
  #
246
246
  # @return [Tina4::QueryBuilder]
247
247
  def query
248
- QueryBuilder.from_table(table_name, db: db)
248
+ QueryBuilder.from_table(table_name, db: db, primary_key: get_db_column(primary_key_field || :id))
249
249
  end
250
250
 
251
251
  def find(id_or_filter = nil, filter = nil, **kwargs)
@@ -480,13 +480,25 @@ module Tina4
480
480
 
481
481
  # Invalidate every cached query that touches this model's table.
482
482
  #
483
- # Tag-scoped, NOT a wholesale flush: a cached JOIN on another model that
484
- # reads this table is busted too (it carries this table's tag), while a
485
- # query that never touches this table is left intact. Called after every
486
- # ORM write (save/delete/force_delete/restore) so a read-after-write never
487
- # serves a stale/deleted row (CACHE-DEC-01).
483
+ # Tag-scoped in the ORM layer (a cached JOIN on another model that reads
484
+ # this table is busted too because it carries this table's tag; a query
485
+ # that never touches this table is left intact), then cascaded to the
486
+ # DB layer on this model's bound connection so an out-of-band write /
487
+ # deliberate refresh / race-with-another-process cannot leave stale rows
488
+ # in db.fetch's persistent cache. Called after every ORM write
489
+ # (save/delete/force_delete/restore) so a read-after-write never serves
490
+ # a stale/deleted row (CACHE-DEC-01). 3.13.105 (parity port of PY-06-22)
491
+ # added the DB-layer cascade — previously the two cache layers disagreed
492
+ # under TINA4_AUTO_CACHING=true + TINA4_DB_CACHE=true.
488
493
  def clear_cache
489
494
  query_cache.clear_tag(table_name.to_s.downcase)
495
+ begin
496
+ get_db.cache_clear
497
+ rescue StandardError
498
+ # A resolvable DB is not guaranteed at every clear_cache call site
499
+ # (module-import time in odd bootstraps, tests that mutate bindings);
500
+ # never let a cache-clear crash a save/delete.
501
+ end
490
502
  nil
491
503
  end
492
504
 
@@ -529,7 +541,7 @@ module Tina4
529
541
  end
530
542
 
531
543
  def create_table
532
- return true if db.table_exists?(table_name)
544
+ return create_spatial_indexes if db.table_exists?(table_name)
533
545
 
534
546
  # v3.13.16: engine-aware DDL. Ruby used to emit SQLite-only DDL on
535
547
  # every driver — INTEGER for booleans, DATETIME for datetimes, and a
@@ -594,12 +606,17 @@ module Tina4
594
606
  datetime: datetime_sql,
595
607
  timestamp: "TIMESTAMP",
596
608
  blob: "BLOB",
597
- json: json_sql
609
+ json: json_sql,
610
+ point: nil
598
611
  }
599
612
 
600
613
  col_defs = []
601
614
  field_definitions.each do |name, opts|
602
- sql_type = type_map[opts[:type]] || "TEXT"
615
+ sql_type = if opts[:type] == :point
616
+ SQLTranslator.point_column_type(engine, opts[:srid] || Point::DEFAULT_SRID)
617
+ else
618
+ type_map[opts[:type]] || "TEXT"
619
+ end
603
620
  if opts[:type] == :string && opts[:length]
604
621
  sql_type = "VARCHAR(#{opts[:length]})"
605
622
  elsif opts[:type] == :decimal
@@ -687,13 +704,35 @@ module Tina4
687
704
  # ONE case is benign - re-raise anything else.
688
705
  raise ce unless ce.message.to_s =~ /no corresponding begin/i
689
706
  end
690
- true
707
+ create_spatial_indexes
708
+ rescue SpatialNotSupportedError
709
+ raise
691
710
  rescue => e
692
711
  Tina4::Log.error("create_table failed for #{table_name}: #{db.get_error || e.message}", { sql: sql })
693
712
  false
694
713
  end
695
714
  end
696
715
 
716
+ def create_spatial_indexes
717
+ fields = field_definitions.select { |_name, opts| opts[:type] == :point }
718
+ return true if fields.empty?
719
+ engine = db.get_database_type.to_s.downcase
720
+ begin
721
+ fields.each do |name, opts|
722
+ SQLTranslator.point_column_type(engine, opts[:srid] || Point::DEFAULT_SRID)
723
+ next unless opts.fetch(:spatial_index, true)
724
+ db.execute(SQLTranslator.spatial_index(engine, table_name, get_db_column(name)))
725
+ end
726
+ db.commit
727
+ true
728
+ rescue SpatialNotSupportedError
729
+ raise
730
+ rescue => e
731
+ Tina4::Log.error("spatial index creation failed for #{table_name}: #{e.message}")
732
+ false
733
+ end
734
+ end
735
+
697
736
  def scope(name, filter_sql, params = [])
698
737
  define_singleton_method(name) do |limit: 100, offset: 0|
699
738
  where(filter_sql, params, limit: limit, offset: offset)
@@ -721,6 +760,9 @@ module Tina4
721
760
  # leave the raw string in place
722
761
  end
723
762
  end
763
+ if fdef && fdef[:type] == :point && !value.nil?
764
+ value = Point.parse(value, srid: fdef[:srid] || Point::DEFAULT_SRID)
765
+ end
724
766
  setter = "#{attr_name}="
725
767
  instance.__send__(setter, value) if instance.respond_to?(setter)
726
768
  end
@@ -857,6 +899,12 @@ module Tina4
857
899
  # a.meta must not leak into b.meta). Parity with the Python master,
858
900
  # which deepcopies a JSONField's dict/list default per instance.
859
901
  d = Marshal.load(Marshal.dump(d)) if d.is_a?(Hash) || d.is_a?(Array)
902
+ if opts[:type] == :point && !d.nil?
903
+ d = Point.parse(d, srid: opts[:srid] || Point::DEFAULT_SRID)
904
+ if d.srid != (opts[:srid] || Point::DEFAULT_SRID)
905
+ raise ArgumentError, "Point field '#{name}' expects SRID #{opts[:srid] || Point::DEFAULT_SRID}; received #{d.srid}"
906
+ end
907
+ end
860
908
  # #165: seed the default straight into the ivar, BYPASSING the
861
909
  # tracking setter, so a default is not recorded as a caller
862
910
  # assignment (mirrors the Python master's object.__setattr__).
@@ -1283,7 +1331,8 @@ module Tina4
1283
1331
  key_case = binding.local_variable_get(:case) # :case is a reserved word
1284
1332
  hash = {}
1285
1333
  self.class.field_definitions.each_key do |name|
1286
- hash[name] = __send__(name)
1334
+ value = __send__(name)
1335
+ hash[name] = value.is_a?(Point) ? value.geojson : value
1287
1336
  end
1288
1337
 
1289
1338
  if include
@@ -1321,6 +1370,19 @@ module Tina4
1321
1370
  hash
1322
1371
  end
1323
1372
 
1373
+ def to_feature(geometry_field: nil, include: nil)
1374
+ point_fields = self.class.field_definitions.select { |_name, opts| opts[:type] == :point }.keys
1375
+ geometry_field = (geometry_field || point_fields.first)&.to_sym
1376
+ raise ArgumentError, "to_feature needs a declared point_field" unless geometry_field && point_fields.include?(geometry_field)
1377
+ properties = to_h(include: include)
1378
+ geometry = properties.delete(geometry_field)
1379
+ { type: "Feature", geometry: geometry, properties: properties }
1380
+ end
1381
+
1382
+ def self.feature_collection(models, geometry_field: nil, include: nil)
1383
+ { type: "FeatureCollection", features: models.map { |model| model.to_feature(geometry_field: geometry_field, include: include) } }
1384
+ end
1385
+
1324
1386
  alias to_hash to_h
1325
1387
  alias to_dict to_h
1326
1388
  alias to_object to_h
@@ -1377,6 +1439,7 @@ module Tina4
1377
1439
  if opts[:type] == :json && !value.nil? && !value.is_a?(String)
1378
1440
  value = JSON.generate(value)
1379
1441
  end
1442
+ value = value.ewkt if opts[:type] == :point && value.is_a?(Point)
1380
1443
  db_col = mapping[name.to_s] || name
1381
1444
  hash[db_col.to_sym] = value
1382
1445
  end
@@ -1400,6 +1463,7 @@ module Tina4
1400
1463
  if opts[:type] == :json && !value.nil? && !value.is_a?(String)
1401
1464
  value = JSON.generate(value)
1402
1465
  end
1466
+ value = value.ewkt if opts[:type] == :point && value.is_a?(Point)
1403
1467
  db_col = mapping[name.to_s] || name
1404
1468
  hash[db_col.to_sym] = value
1405
1469
  end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Tina4
6
+ class SpatialNotSupportedError < StandardError; end
7
+
8
+ # Immutable SRID-aware longitude/latitude point (ADR-0057).
9
+ class Point
10
+ DEFAULT_SRID = 4326
11
+ EWKB_SRID = 0x20000000
12
+ EWKB_M = 0x40000000
13
+ EWKB_Z = 0x80000000
14
+
15
+ attr_reader :lon, :lat, :srid
16
+
17
+ def initialize(lon, lat, srid: DEFAULT_SRID)
18
+ raise ArgumentError, "Point longitude and latitude must be numbers" if lon == true || lon == false || lat == true || lat == false
19
+ @lon = Float(lon)
20
+ @lat = Float(lat)
21
+ @srid = Integer(srid)
22
+ raise ArgumentError, "Point longitude and latitude must be finite" unless @lon.finite? && @lat.finite?
23
+ if @srid == DEFAULT_SRID
24
+ raise ArgumentError, "Point longitude #{@lon} is outside -180..180; Tina4 uses longitude, latitude order" unless (-180.0..180.0).cover?(@lon)
25
+ raise ArgumentError, "Point latitude #{@lat} is outside -90..90; Tina4 uses longitude, latitude order" unless (-90.0..90.0).cover?(@lat)
26
+ end
27
+ freeze
28
+ rescue TypeError, ArgumentError => e
29
+ raise e if e.message.start_with?("Point ")
30
+ raise ArgumentError, "Point longitude, latitude and SRID must be numeric"
31
+ end
32
+
33
+ def wkt = "POINT(#{format_coordinate(@lon)} #{format_coordinate(@lat)})"
34
+ def ewkt = "SRID=#{@srid};#{wkt}"
35
+ def geojson = { type: "Point", coordinates: [@lon, @lat] }
36
+ def to_h = geojson
37
+ def to_a = [@lon, @lat]
38
+ def to_json(*args) = geojson.to_json(*args)
39
+
40
+ def self.parse(value, srid: DEFAULT_SRID)
41
+ return value if value.is_a?(Point)
42
+ if value.is_a?(Array)
43
+ raise ArgumentError, "Point coordinate pair needs longitude and latitude" if value.length < 2
44
+ return new(value[0], value[1], srid: srid)
45
+ end
46
+ return from_geojson(value, srid) if value.is_a?(Hash)
47
+ if value.is_a?(String)
48
+ text = value.strip
49
+ match = text.match(/\A(?: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*\)\z/i)
50
+ return new(match[2], match[3], srid: match[1] ? match[1].to_i : srid) if match
51
+ raw = [text].pack("H*") if text.length >= 42 && text.length.even? && text.match?(/\A[0-9a-f]+\z/i)
52
+ raw ||= value.b if [0, 1].include?(value.getbyte(0))
53
+ return from_wkb(raw, srid) if raw
54
+ end
55
+ raise ArgumentError, "Point must be Point, [longitude, latitude], WKT/EWKT, GeoJSON or WKB/EWKB"
56
+ end
57
+
58
+ def self.geometry_binding(value, srid: DEFAULT_SRID)
59
+ return [parse(value, srid: srid).ewkt, :ewkt] if value.is_a?(Point) || value.is_a?(Array)
60
+ if value.is_a?(Hash)
61
+ geometry = value.fetch(:type, value["type"]).to_s.downcase == "feature" ? (value[:geometry] || value["geometry"]) : value
62
+ type = (geometry[:type] || geometry["type"]).to_s.downcase
63
+ allowed = %w[point linestring polygon multipoint multilinestring multipolygon geometrycollection]
64
+ raise ArgumentError, "GeoJSON geometry has an unsupported type" unless allowed.include?(type)
65
+ return [JSON.generate(geometry), :geojson]
66
+ end
67
+ if value.is_a?(String) && value.match?(/\A\s*(?:SRID\s*=\s*\d+\s*;\s*)?(?:POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON|GEOMETRYCOLLECTION)\b/i)
68
+ return [value.match?(/\A\s*SRID/i) ? value.strip : "SRID=#{srid};#{value.strip}", :ewkt]
69
+ end
70
+ raise ArgumentError, "Geometry must be Point, coordinate pair, WKT/EWKT or GeoJSON"
71
+ end
72
+
73
+ def self.from_geojson(data, srid)
74
+ type = (data[:type] || data["type"]).to_s.downcase
75
+ geometry = type == "feature" ? (data[:geometry] || data["geometry"] || {}) : data
76
+ raise ArgumentError, "Point GeoJSON type must be Point" unless (geometry[:type] || geometry["type"]).to_s.downcase == "point"
77
+ coordinates = geometry[:coordinates] || geometry["coordinates"]
78
+ raise ArgumentError, "Point GeoJSON coordinates must be [longitude, latitude]" unless coordinates.is_a?(Array) && coordinates.length >= 2
79
+ new(coordinates[0], coordinates[1], srid: srid)
80
+ end
81
+ private_class_method :from_geojson
82
+
83
+ def self.from_wkb(raw, srid)
84
+ raise ArgumentError, "Point WKB is too short" if raw.bytesize < 21
85
+ little = raw.getbyte(0) == 1
86
+ type_word = raw.byteslice(1, 4).unpack1(little ? "V" : "N")
87
+ offset = 5
88
+ if (type_word & EWKB_SRID) != 0
89
+ srid = raw.byteslice(5, 4).unpack1(little ? "V" : "N")
90
+ offset = 9
91
+ end
92
+ code = (type_word & ~(EWKB_SRID | EWKB_Z | EWKB_M)) % 1000
93
+ raise ArgumentError, "WKB geometry is not a Point" unless code == 1 && raw.bytesize >= offset + 16
94
+ lon, lat = raw.byteslice(offset, 16).unpack(little ? "E2" : "G2")
95
+ new(lon, lat, srid: srid)
96
+ end
97
+ private_class_method :from_wkb
98
+
99
+ private
100
+
101
+ def format_coordinate(value)
102
+ text = format("%.15g", value)
103
+ text == "-0" ? "0" : text
104
+ end
105
+ end
106
+ end
@@ -19,10 +19,11 @@ module Tina4
19
19
  # .get
20
20
  #
21
21
  class QueryBuilder
22
- def initialize(table, db: nil)
22
+ def initialize(table, db: nil, primary_key: nil)
23
23
  @table = table
24
24
  @db = db
25
25
  @columns = ["*"]
26
+ @select_params = []
26
27
  @wheres = []
27
28
  @params = []
28
29
  @joins = []
@@ -30,6 +31,8 @@ module Tina4
30
31
  @havings = []
31
32
  @having_params = []
32
33
  @order_by_cols = []
34
+ @order_by_params = []
35
+ @primary_key = primary_key&.to_s
33
36
  @limit_val = nil
34
37
  @offset_val = nil
35
38
  end
@@ -39,8 +42,8 @@ module Tina4
39
42
  # @param table_name [String] The database table name.
40
43
  # @param db [Object, nil] Optional database connection.
41
44
  # @return [QueryBuilder]
42
- def self.from_table(table_name, db: nil)
43
- new(table_name, db: db)
45
+ def self.from_table(table_name, db: nil, primary_key: nil)
46
+ new(table_name, db: db, primary_key: primary_key)
44
47
  end
45
48
 
46
49
  # Set the columns to select.
@@ -48,7 +51,10 @@ module Tina4
48
51
  # @param columns [Array<String>] Column names.
49
52
  # @return [self]
50
53
  def select(*columns)
51
- @columns = columns unless columns.empty?
54
+ unless columns.empty?
55
+ @columns = columns
56
+ @select_params = []
57
+ end
52
58
  self
53
59
  end
54
60
 
@@ -123,6 +129,46 @@ module Tina4
123
129
  self
124
130
  end
125
131
 
132
+ def within_distance(column, point, radius_metres, srid: Point::DEFAULT_SRID)
133
+ radius = Float(radius_metres)
134
+ raise ArgumentError, "Spatial radius must be finite and greater than or equal to zero" unless radius.finite? && radius >= 0
135
+ point = Point.parse(point, srid: srid)
136
+ where(SQLTranslator.within_distance(engine, column, point.srid), [point.lon, point.lat, radius])
137
+ end
138
+
139
+ def intersects(column, geometry, srid: Point::DEFAULT_SRID)
140
+ bound, form = Point.geometry_binding(geometry, srid: srid)
141
+ where(SQLTranslator.intersects(engine, column, form, srid), [bound])
142
+ end
143
+
144
+ def bbox(column, min_lon, min_lat, max_lon, max_lat, srid: Point::DEFAULT_SRID)
145
+ values = [min_lon, min_lat, max_lon, max_lat].map { |value| Float(value) }
146
+ raise ArgumentError, "Bounding-box coordinates must be finite" unless values.all?(&:finite?)
147
+ west, south, east, north = values
148
+ Point.new(west, south, srid: srid)
149
+ Point.new(east, north, srid: srid)
150
+ raise ArgumentError, "Bounding box must be ordered west, south, east, north" if west > east || south > north
151
+ where(SQLTranslator.bbox(engine, column, srid), values)
152
+ end
153
+
154
+ def select_distance(column, point, alias_name: "distance", srid: Point::DEFAULT_SRID)
155
+ point = Point.parse(point, srid: srid)
156
+ @columns << SQLTranslator.distance_as(engine, column, alias_name, point.srid)
157
+ @select_params.concat([point.lon, point.lat])
158
+ self
159
+ end
160
+
161
+ def order_by_distance(column, point, direction: "ASC", srid: Point::DEFAULT_SRID)
162
+ direction = direction.to_s.upcase
163
+ raise ArgumentError, "Distance order direction must be ASC or DESC" unless %w[ASC DESC].include?(direction)
164
+ raise ArgumentError, "Stable spatial ordering needs a primary key; use ORM.query or pass primary_key:" if @primary_key.to_s.empty?
165
+ point = Point.parse(point, srid: srid)
166
+ @order_by_cols << "#{SQLTranslator.distance(engine, column, point.srid)} #{direction}"
167
+ @order_by_params.concat([point.lon, point.lat])
168
+ @order_by_cols << "#{SQLTranslator.spatial_identifier(@primary_key, 'primary key')} ASC"
169
+ self
170
+ end
171
+
126
172
  # Set LIMIT and optional OFFSET.
127
173
  #
128
174
  # @param count [Integer] Maximum rows to return.
@@ -167,7 +213,7 @@ module Tina4
167
213
  def get
168
214
  ensure_db!
169
215
  sql = to_sql
170
- all_params = @params + @having_params
216
+ all_params = @select_params + @params + @having_params + @order_by_params
171
217
 
172
218
  @db.fetch(
173
219
  sql,
@@ -183,7 +229,7 @@ module Tina4
183
229
  def first
184
230
  ensure_db!
185
231
  sql = to_sql
186
- all_params = @params + @having_params
232
+ all_params = @select_params + @params + @having_params + @order_by_params
187
233
 
188
234
  @db.fetch_one(sql, all_params.empty? ? [] : all_params)
189
235
  end
@@ -196,9 +242,18 @@ module Tina4
196
242
 
197
243
  # Build a count query by replacing columns
198
244
  original = @columns
245
+ original_select_params = @select_params
246
+ original_order = @order_by_cols
247
+ original_order_params = @order_by_params
199
248
  @columns = ["COUNT(*) as cnt"]
249
+ @select_params = []
250
+ @order_by_cols = []
251
+ @order_by_params = []
200
252
  sql = to_sql
201
253
  @columns = original
254
+ @select_params = original_select_params
255
+ @order_by_cols = original_order
256
+ @order_by_params = original_order_params
202
257
 
203
258
  all_params = @params + @having_params
204
259
 
@@ -272,6 +327,11 @@ module Tina4
272
327
 
273
328
  private
274
329
 
330
+ def engine
331
+ ensure_db!
332
+ @db.get_database_type
333
+ end
334
+
275
335
  # Parse a single SQL condition into a MongoDB filter hash.
276
336
  #
277
337
  # @return [Array(Hash, Integer)] [mongo_condition, updated_param_index]
data/lib/tina4/queue.rb CHANGED
@@ -171,7 +171,14 @@ module Tina4
171
171
  @backend.close
172
172
  end
173
173
 
174
- # Get jobs that failed but are still eligible for retry (under max_retries).
174
+ # Get jobs that failed at least once but are still being retried
175
+ # (0 < attempts < max_retries). These live in the pending queue under
176
+ # the auto-retry lifecycle (fail() re-queues them with an incremented
177
+ # attempts count and a retry_backoff delay) so pop() picks them up
178
+ # again. They are NOT counted by size("failed") — that alias counts
179
+ # the dead-letter store, matching dead_letters(). To include retryable-
180
+ # failed jobs in a total, use size("pending"). Terminal failures are
181
+ # returned by dead_letters().
175
182
  def failed # -> list[dict]
176
183
  refuse_operation!("failed()") unless @backend.respond_to?(:failed)
177
184
  @backend.failed(@topic, max_retries: @max_retries)
@@ -179,13 +186,24 @@ module Tina4
179
186
 
180
187
  # Retry a specific failed job by ID, or all dead-letter jobs if no id given.
181
188
  # Returns true if re-queued.
189
+ #
190
+ # The no-arg branch is materialised by the backend (LiteBackend#retry_job
191
+ # iterates every dead-letter file with .each, MongoBackend materialises
192
+ # find(...).to_a before iterating) — no generator inside any() /
193
+ # short-circuit reduce can silently leave dead letters behind (parity port
194
+ # of PY-12-04, 3.13.105).
182
195
  def retry(job_id = nil, delay_seconds: 0) # -> bool
183
196
  refuse_operation!("retry()") unless @backend.respond_to?(:retry_job)
184
197
  @backend.retry_job(@topic, job_id: job_id, delay_seconds: delay_seconds)
185
198
  end
186
199
 
187
- # Get dead letter jobs — messages that exceeded max retries.
188
- # Pass max_retries to override the queue's default.
200
+ # Get jobs that exceeded max_retries — terminal failures.
201
+ #
202
+ # Same set counted by size("failed") / size("dead") / size("dead_letter")
203
+ # (three aliases for the dead-letter store). To LIST retryable-but-
204
+ # attempted jobs (attempts > 0 AND attempts < max_retries) that are still
205
+ # being auto-retried, use failed() — those live in the pending queue and
206
+ # are NOT dead letters. Pass max_retries to override the queue's default.
189
207
  def dead_letters(max_retries: nil) # -> list[dict]
190
208
  refuse_operation!("dead_letters()") unless @backend.respond_to?(:dead_letters)
191
209
  @backend.dead_letters(@topic, max_retries: max_retries || @max_retries)
@@ -328,9 +346,21 @@ module Tina4
328
346
  attach(@backend.find_by_id(topic || @topic, id))
329
347
  end
330
348
 
331
- # Get the number of messages by status.
332
- # status: "pending" (default) counts pending messages in the topic queue.
333
- # status: "failed" or "dead" counts messages in the dead_letter directory.
349
+ # Count jobs by status.
350
+ #
351
+ # "pending" counts jobs waiting to be popped INCLUDES retryable-but-
352
+ # attempted ones, because they live in the pending queue under the
353
+ # auto-retry lifecycle (see failed()).
354
+ # "reserved" counts jobs a consumer has popped but not yet
355
+ # completed/failed (in-flight against the visibility timeout).
356
+ # "completed" counts jobs the consumer has finished successfully
357
+ # (0 on the lite/file backend which deletes on complete; backends that
358
+ # track completion expose completed_count).
359
+ # "failed", "dead", "dead_letter" are ALIASES that all count the
360
+ # dead-letter store — jobs whose attempts >= max_retries and that have
361
+ # given up. Use dead_letters() to list them. Retryable-but-attempted
362
+ # jobs are NOT counted by size("failed"); use failed() to list them or
363
+ # size("pending") to include them in a total.
334
364
  def size(status: "pending")
335
365
  case status.to_s
336
366
  when "pending"
@@ -164,9 +164,26 @@ module Tina4
164
164
 
165
165
  # Explicit re-queue requested by the caller (job.retry()). Always
166
166
  # re-enqueues regardless of the retry limit — a manual override, distinct
167
- # from the automatic fail() path. Increments attempts, clears the error.
167
+ # from the automatic fail() path. Cleans up BOTH the reservation record
168
+ # and any dead-letter file for this id, so a caller who iterates
169
+ # dead_letters and calls job.retry on each doesn't leave the dead-letter
170
+ # directory carrying duplicates (3.13.105, parity port of PY-12-05).
171
+ # Aligns with retry_job(id) which had always unlinked the dead-letter
172
+ # file — two spellings of the same intent that previously diverged.
173
+ # Increments attempts, clears the error.
168
174
  def retry(job, delay_seconds: 0)
169
175
  clear_reservation(job.topic, job.id)
176
+ # Drop any dead-letter file for this id BEFORE the re-queue: if this
177
+ # job came from dead_letters it lives in @dead_letter_dir and would
178
+ # otherwise stay on disk while a fresh pending file appears in
179
+ # topic_path, so the next dead_letters call reports the job again
180
+ # and a consumer processes it twice.
181
+ begin
182
+ File.unlink(job_file(@dead_letter_dir, job.id))
183
+ rescue Errno::ENOENT
184
+ # No dead-letter file for this id (manual .retry on a live job) —
185
+ # nothing to clean up.
186
+ end
170
187
  job.attempts += 1
171
188
  requeue_job(job, delay_seconds: delay_seconds, error: nil)
172
189
  end
@@ -291,8 +291,19 @@ module Tina4
291
291
  .map { |doc| job_from_doc(doc) }
292
292
  end
293
293
 
294
+ # Delete every doc in this queue whose status matches.
295
+ #
296
+ # 3.13.105 (parity port): route the dead-letter statuses ("dead",
297
+ # "failed", "dead_letter") to the ".dead_letter" topic namespace where
298
+ # they actually live — pre-fix, purge("dead") delete_many'd on
299
+ # {topic: topic, status: "dead"}, matching zero docs and silently
300
+ # returning 0 while the dead letters sat untouched in the sibling
301
+ # namespace. Every path scopes by status so a purge never nukes docs it
302
+ # was not asked to remove, and every path returns deleted_count so a
303
+ # caller can log/assert what was purged.
294
304
  def purge(topic, status)
295
- result = collection.delete_many(topic: topic, status: status.to_s)
305
+ namespace = dead_status?(status) ? "#{topic}.dead_letter" : topic
306
+ result = collection.delete_many(topic: namespace, status: status.to_s)
296
307
  result.deleted_count
297
308
  end
298
309
 
@@ -326,20 +337,31 @@ module Tina4
326
337
  result.modified_count
327
338
  end
328
339
 
329
- # Move ONE dead-lettered job back to its main topic as pending.
330
- # Returns true when a job was revived, false when the id was not found.
340
+ # Move dead-lettered jobs back to their main topic as pending.
341
+ #
342
+ # With +job_id+, revives that ONE dead letter and returns true when
343
+ # found (false otherwise). With no id, iterates every dead letter for
344
+ # the topic and revives ALL — materialised into an array before
345
+ # reducing, so a truthy first result never short-circuits away the
346
+ # rest (the class of bug the parity port of PY-12-04 pins). Ruby's
347
+ # dead-letter model stores the SAME document with topic flipped to
348
+ # "<topic>.dead_letter", so reviving is the reverse in-place update:
349
+ # flip the topic back, reset status/available_at/error. Mirrors
350
+ # LiteBackend#retry_job.
331
351
  def retry_job(topic, job_id: nil, delay_seconds: 0)
332
352
  filter = { topic: "#{topic}.dead_letter", status: "dead" }
333
353
  filter[:_id] = job_id if job_id
334
- doc = collection.find(filter).first
335
- return false unless doc
354
+ docs = collection.find(filter).to_a
355
+ return false if docs.empty?
336
356
 
337
357
  available = delay_seconds.to_f > 0 ? (Time.now.utc + delay_seconds.to_f) : Time.now.utc
338
- collection.update_one(
339
- { _id: doc["_id"] },
340
- { "$set" => { topic: topic, status: "pending", error: nil,
341
- reserved_at: nil, available_at: available } }
342
- )
358
+ docs.each do |doc|
359
+ collection.update_one(
360
+ { _id: doc["_id"] },
361
+ { "$set" => { topic: topic, status: "pending", error: nil,
362
+ reserved_at: nil, available_at: available } }
363
+ )
364
+ end
343
365
  true
344
366
  end
345
367
 
@@ -358,6 +380,12 @@ module Tina4
358
380
 
359
381
  private
360
382
 
383
+ DEAD_STATES = %w[failed dead dead_letter].freeze
384
+
385
+ def dead_status?(status)
386
+ DEAD_STATES.include?(status.to_s)
387
+ end
388
+
361
389
  # A stored document as a plain Hash with string keys, CARRYING attempts
362
390
  # and error.
363
391
  #
@@ -1174,6 +1174,12 @@ module Tina4
1174
1174
  # the auth gate into a 500. It degrades to an empty session, which means
1175
1175
  # no token, which means the ordinary 401 below - a SERVED request.
1176
1176
  session = Tina4::Session.new(env, degrade_on_backend_failure: true)
1177
+ sso = session.get("_tina4_sso")
1178
+ identity = sso.is_a?(Hash) ? sso["identity"] : nil
1179
+ if identity.is_a?(Hash) && identity["issuer"] && identity["subject"]
1180
+ env["tina4.auth_payload"] = identity
1181
+ return nil
1182
+ end
1177
1183
  session_token = session.get("token")
1178
1184
  if session_token && !session_token.empty?
1179
1185
  token = session_token
data/lib/tina4/session.rb CHANGED
@@ -237,7 +237,7 @@ module Tina4
237
237
 
238
238
  # Return all session data
239
239
  def all
240
- @data.dup
240
+ @data.reject { |key, _| %w[_tina4_sso _tina4_sso_pending].include?(key.to_s) }
241
241
  end
242
242
 
243
243
  # Flash data: set a value that is removed after next read.
@@ -16,7 +16,70 @@ module Tina4
16
16
  # # => "SELECT * FROM users ROWS 6 TO 15"
17
17
  #
18
18
  class SQLTranslator
19
+ SPATIAL_ENGINES = %w[postgres postgresql].freeze
20
+ SPATIAL_IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*\z/
21
+
19
22
  class << self
23
+ def require_spatial(engine, feature)
24
+ name = engine.to_s.downcase
25
+ return name if SPATIAL_ENGINES.include?(name)
26
+ raise SpatialNotSupportedError,
27
+ "#{feature} is not supported on the '#{name.empty? ? 'unknown' : name}' database engine. " \
28
+ "Tina4 GIS support is PostGIS-first: use PostgreSQL with CREATE EXTENSION postgis. " \
29
+ "Tina4 will not replace a spatial query with an approximate coordinate query."
30
+ end
31
+
32
+ def spatial_identifier(name, what = "column")
33
+ text = name.to_s
34
+ raise ArgumentError, "Spatial #{what} is not a valid SQL identifier: #{text}" unless SPATIAL_IDENTIFIER.match?(text)
35
+ text
36
+ end
37
+
38
+ def point_column_type(engine, srid = Point::DEFAULT_SRID)
39
+ require_spatial(engine, "PointField")
40
+ "geography(Point,#{Integer(srid)})"
41
+ end
42
+
43
+ def spatial_index(engine, table, column)
44
+ require_spatial(engine, "spatial index creation")
45
+ table = spatial_identifier(table, "table")
46
+ column = spatial_identifier(column)
47
+ "CREATE INDEX IF NOT EXISTS #{table.tr('.', '_')}_#{column}_gist ON #{table} USING GIST (#{column})"
48
+ end
49
+
50
+ def point_literal(engine, srid = Point::DEFAULT_SRID)
51
+ require_spatial(engine, "spatial predicates")
52
+ "ST_SetSRID(ST_MakePoint(?, ?), #{Integer(srid)})::geography"
53
+ end
54
+
55
+ def within_distance(engine, column, srid = Point::DEFAULT_SRID)
56
+ "ST_DWithin(#{spatial_identifier(column)}, #{point_literal(engine, srid)}, ?)"
57
+ end
58
+
59
+ def distance(engine, column, srid = Point::DEFAULT_SRID)
60
+ "ST_Distance(#{spatial_identifier(column)}, #{point_literal(engine, srid)})"
61
+ end
62
+
63
+ def distance_as(engine, column, alias_name, srid = Point::DEFAULT_SRID)
64
+ "#{distance(engine, column, srid)} AS #{spatial_identifier(alias_name, 'result alias')}"
65
+ end
66
+
67
+ def geometry_literal(engine, form = :ewkt, srid = Point::DEFAULT_SRID)
68
+ require_spatial(engine, "spatial predicates")
69
+ return "ST_GeogFromText(?)" if form.to_sym == :ewkt
70
+ return "ST_SetSRID(ST_GeomFromGeoJSON(?), #{Integer(srid)})::geography" if form.to_sym == :geojson
71
+ raise ArgumentError, "Unsupported spatial geometry form: #{form}"
72
+ end
73
+
74
+ def intersects(engine, column, form = :ewkt, srid = Point::DEFAULT_SRID)
75
+ "ST_Intersects(#{spatial_identifier(column)}, #{geometry_literal(engine, form, srid)})"
76
+ end
77
+
78
+ def bbox(engine, column, srid = Point::DEFAULT_SRID)
79
+ require_spatial(engine, "bbox")
80
+ "ST_Intersects(#{spatial_identifier(column)}, ST_MakeEnvelope(?, ?, ?, ?, #{Integer(srid)})::geography)"
81
+ end
82
+
20
83
  # ── Literal-safe rewriting ────────────────────────────────────
21
84
  #
22
85
  # A dialect rewrite (|| -> CONCAT, TRUE -> 1, ILIKE -> LOWER LIKE) must NEVER
data/lib/tina4/sso.rb ADDED
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "digest"
5
+ require "json"
6
+ require "net/http"
7
+ require "openssl"
8
+ require "securerandom"
9
+ require "uri"
10
+
11
+ module Tina4
12
+ class SsoError < StandardError; end
13
+
14
+ # Provider-neutral, configuration-first OpenID Connect SSO.
15
+ class Sso
16
+ PENDING_KEY = "_tina4_sso_pending"
17
+ SESSION_KEY = "_tina4_sso"
18
+ attr_reader :issuer, :client_id, :client_secret, :redirect_uri, :scopes,
19
+ :verify, :post_logout_redirect_uri, :claim_map
20
+ @mounted = false
21
+
22
+ def initialize(options = {})
23
+ @issuer = (options[:issuer] || ENV["TINA4_SSO_ISSUER"] || "").sub(%r{/$}, "")
24
+ @client_id = options[:client_id] || ENV["TINA4_SSO_CLIENT_ID"] || ""
25
+ @client_secret = options.key?(:client_secret) ? options[:client_secret] : ENV["TINA4_SSO_CLIENT_SECRET"]
26
+ @redirect_uri = options[:redirect_uri] || ENV["TINA4_SSO_REDIRECT_URI"] || ""
27
+ @scopes = options[:scopes] || json_env("TINA4_SSO_SCOPES", %w[openid profile email])
28
+ @verify = (options[:verify] || ENV["TINA4_SSO_VERIFY"] || "introspection").downcase
29
+ @post_logout_redirect_uri = options[:post_logout_redirect_uri] || ENV["TINA4_SSO_POST_LOGOUT_REDIRECT_URI"]
30
+ @claim_map = options[:claim_map] || json_env("TINA4_SSO_CLAIM_MAP", {})
31
+ @metadata = {}
32
+ validate_config!
33
+ end
34
+
35
+ def self.from_issuer(options = {})
36
+ new(options).tap(&:discover)
37
+ end
38
+
39
+ def self.configured?
40
+ %w[TINA4_SSO_ISSUER TINA4_SSO_CLIENT_ID TINA4_SSO_REDIRECT_URI].all? { |key| !ENV[key].to_s.empty? }
41
+ end
42
+
43
+ def json_env(name, fallback)
44
+ raw = ENV[name]
45
+ raw.nil? || raw.empty? ? fallback : JSON.parse(raw)
46
+ rescue JSON::ParserError => e
47
+ raise SsoError, "#{name} must be valid JSON", cause: e
48
+ end
49
+
50
+ def self.secure_url!(value, name)
51
+ uri = URI.parse(value)
52
+ raise SsoError, "#{name} must be an absolute URL" unless uri.absolute? && uri.host
53
+
54
+ loopback = %w[localhost 127.0.0.1 ::1].include?(uri.host)
55
+ raise SsoError, "#{name} must use HTTPS except on loopback" unless uri.scheme == "https" || (uri.scheme == "http" && loopback)
56
+ rescue URI::InvalidURIError => e
57
+ raise SsoError, "#{name} must be an absolute URL", cause: e
58
+ end
59
+
60
+ def validate_config!
61
+ if @issuer.empty? || @client_id.empty? || @redirect_uri.empty?
62
+ raise SsoError, "TINA4_SSO_ISSUER, TINA4_SSO_CLIENT_ID and TINA4_SSO_REDIRECT_URI are required"
63
+ end
64
+ self.class.secure_url!(@issuer, "issuer")
65
+ self.class.secure_url!(@redirect_uri, "redirect URI")
66
+ raise SsoError, "TINA4_SSO_VERIFY must be introspection or jwks" unless %w[introspection jwks].include?(@verify)
67
+ raise SsoError, "jwks verification requires an installed cryptography capability" if @verify == "jwks"
68
+ raise SsoError, "introspection verification requires TINA4_SSO_CLIENT_SECRET" if @verify == "introspection" && @client_secret.to_s.empty?
69
+ raise SsoError, "TINA4_SSO_SCOPES must be a list containing openid" unless @scopes.is_a?(Array) && @scopes.include?("openid")
70
+ end
71
+
72
+ def request_json(url, form: nil, bearer: nil, basic: false)
73
+ uri = URI.parse(url)
74
+ request = form ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
75
+ request["Accept"] = "application/json"
76
+ request.set_form_data(form.transform_values(&:to_s)) if form
77
+ request["Authorization"] = "Bearer #{bearer}" if bearer
78
+ request.basic_auth(@client_id, @client_secret) if basic
79
+ http = Net::HTTP.new(uri.host, uri.port)
80
+ http.use_ssl = uri.scheme == "https"
81
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
82
+ http.open_timeout = 10
83
+ http.read_timeout = 10
84
+ response = http.request(request)
85
+ raise SsoError, "OIDC provider request failed" unless response.is_a?(Net::HTTPSuccess)
86
+ result = JSON.parse(response.body)
87
+ raise SsoError, "OIDC provider returned a non-object response" unless result.is_a?(Hash)
88
+ result
89
+ rescue JSON::ParserError, IOError, SystemCallError, Timeout::Error => e
90
+ raise SsoError, "OIDC provider request failed", cause: e
91
+ end
92
+
93
+ def discover(force = false)
94
+ return @metadata.dup unless @metadata.empty? || force
95
+ result = request_json("#{@issuer}/.well-known/openid-configuration")
96
+ raise SsoError, "OIDC discovery issuer does not exactly match configuration" unless result["issuer"] == @issuer
97
+ required = %w[authorization_endpoint token_endpoint]
98
+ required << "introspection_endpoint" if @verify == "introspection"
99
+ required.each do |key|
100
+ raise SsoError, "OIDC discovery is missing #{key}" if result[key].to_s.empty?
101
+ self.class.secure_url!(result[key], key)
102
+ end
103
+ @metadata = result
104
+ result.dup
105
+ end
106
+
107
+ def self.safe_return(value)
108
+ return "/" if value.to_s.empty? || !value.start_with?("/") || value.start_with?("//") || value.include?("\\")
109
+ value.each_byte.any? { |byte| byte < 32 } ? "/" : value
110
+ end
111
+
112
+ def session(value)
113
+ value.is_a?(Tina4::Session) ? value : value&.session
114
+ end
115
+
116
+ def login(request_or_session, return_to = "/")
117
+ current = session(request_or_session)
118
+ raise SsoError, "SSO login requires a Tina4 Session" unless current
119
+ state = SecureRandom.urlsafe_base64(32)
120
+ nonce = SecureRandom.urlsafe_base64(32)
121
+ verifier = SecureRandom.urlsafe_base64(64)
122
+ challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
123
+ current.set(PENDING_KEY, {
124
+ "state" => state, "nonce" => nonce, "verifier" => verifier,
125
+ "return_to" => self.class.safe_return(return_to), "created_at" => Time.now.to_i
126
+ })
127
+ query = URI.encode_www_form(
128
+ client_id: @client_id, redirect_uri: @redirect_uri, response_type: "code",
129
+ scope: @scopes.join(" "), state: state, nonce: nonce,
130
+ code_challenge: challenge, code_challenge_method: "S256"
131
+ )
132
+ "#{discover['authorization_endpoint']}?#{query}"
133
+ end
134
+
135
+ def self.secure_equal(left, right)
136
+ a = left.to_s
137
+ b = right.to_s
138
+ a.bytesize == b.bytesize && OpenSSL.fixed_length_secure_compare(a, b)
139
+ end
140
+
141
+ def self.jwt_payload(token)
142
+ part = token.split(".")[1].to_s
143
+ JSON.parse(Base64.urlsafe_decode64(part.ljust((part.length + 3) / 4 * 4, "=")))
144
+ rescue JSON::ParserError, ArgumentError
145
+ raise SsoError, "provider returned an invalid ID token"
146
+ end
147
+
148
+ def introspect(access_token)
149
+ result = request_json(discover["introspection_endpoint"],
150
+ form: { token: access_token, token_type_hint: "access_token" }, basic: true)
151
+ unless result["active"] == true && result["iss"] == @issuer
152
+ raise SsoError, "OIDC access token is inactive or has the wrong issuer"
153
+ end
154
+ audience = result["aud"] || result["client_id"]
155
+ valid = audience.is_a?(Array) ? audience.include?(@client_id) : audience == @client_id
156
+ valid ||= result["client_id"] == @client_id
157
+ raise SsoError, "OIDC token audience mismatch" unless valid
158
+ result
159
+ end
160
+
161
+ def claim(claims, configured, fallback)
162
+ (configured || fallback).split(".").reduce(claims) { |value, key| value.is_a?(Hash) ? value[key] : nil }
163
+ end
164
+
165
+ def normalize(claims)
166
+ subject = claim(claims, @claim_map["subject"], "sub")
167
+ identity_issuer = claim(claims, @claim_map["issuer"], "iss") || @issuer
168
+ raise SsoError, "OIDC identity is missing a valid issuer or subject" if subject.to_s.empty? || identity_issuer != @issuer
169
+ roles = Array(claim(claims, @claim_map["roles"], "realm_access.roles"))
170
+ roles += Array(claims.dig("resource_access", @client_id, "roles"))
171
+ groups = Array(claim(claims, @claim_map["groups"], "groups"))
172
+ {
173
+ "issuer" => identity_issuer, "subject" => subject,
174
+ "username" => claim(claims, @claim_map["username"], "preferred_username"),
175
+ "email" => claim(claims, @claim_map["email"], "email"),
176
+ "name" => claim(claims, @claim_map["name"], "name"),
177
+ "roles" => roles.map(&:to_s).uniq.sort, "groups" => groups.map(&:to_s).uniq.sort
178
+ }
179
+ end
180
+
181
+ def callback(request_or_session, query = nil)
182
+ current = session(request_or_session)
183
+ values = query || request_or_session.params
184
+ pending = current&.get(PENDING_KEY)
185
+ current&.delete(PENDING_KEY)
186
+ unless pending.is_a?(Hash) && !values["code"].to_s.empty? && self.class.secure_equal(values["state"], pending["state"])
187
+ raise SsoError, "OIDC callback state is invalid or already consumed"
188
+ end
189
+ raise SsoError, "OIDC callback state has expired" if Time.now.to_i - pending.fetch("created_at", 0).to_i > 600
190
+ metadata = discover
191
+ tokens = request_json(metadata["token_endpoint"], form: {
192
+ grant_type: "authorization_code", code: values["code"], redirect_uri: @redirect_uri,
193
+ client_id: @client_id, code_verifier: pending["verifier"]
194
+ }, basic: !@client_secret.to_s.empty?)
195
+ raise SsoError, "OIDC token response is incomplete" if tokens["access_token"].to_s.empty? || tokens["id_token"].to_s.empty?
196
+ raise SsoError, "JWKS verification requires an installed cryptography capability" if @verify == "jwks"
197
+ claims = introspect(tokens["access_token"])
198
+ unless self.class.secure_equal(self.class.jwt_payload(tokens["id_token"])["nonce"], pending["nonce"])
199
+ raise SsoError, "OIDC ID token nonce mismatch"
200
+ end
201
+ claims.merge!(request_json(metadata["userinfo_endpoint"], bearer: tokens["access_token"])) if metadata["userinfo_endpoint"]
202
+ identity = normalize(claims)
203
+ current.regenerate
204
+ current.set(SESSION_KEY, {
205
+ "version" => 1, "identity" => identity, "access_token" => tokens["access_token"],
206
+ "refresh_token" => tokens["refresh_token"], "id_token" => tokens["id_token"],
207
+ "expires_at" => Time.now.to_i + tokens.fetch("expires_in", 0).to_i
208
+ })
209
+ { "identity" => identity, "return_to" => self.class.safe_return(pending["return_to"]) }
210
+ end
211
+
212
+ def identity(request_or_session)
213
+ stored = session(request_or_session)&.get(SESSION_KEY)
214
+ value = stored.is_a?(Hash) ? stored["identity"] : nil
215
+ request_or_session.user = value if value && !request_or_session.is_a?(Tina4::Session)
216
+ value
217
+ end
218
+
219
+ def refresh(request_or_session)
220
+ current = session(request_or_session)
221
+ stored = current&.get(SESSION_KEY)
222
+ unless stored.is_a?(Hash) && !stored["refresh_token"].to_s.empty?
223
+ current&.delete(SESSION_KEY)
224
+ raise SsoError, "OIDC session cannot be refreshed"
225
+ end
226
+ metadata = discover
227
+ tokens = request_json(metadata["token_endpoint"], form: {
228
+ grant_type: "refresh_token", refresh_token: stored["refresh_token"], client_id: @client_id
229
+ }, basic: !@client_secret.to_s.empty?)
230
+ claims = introspect(tokens["access_token"])
231
+ claims.merge!(request_json(metadata["userinfo_endpoint"], bearer: tokens["access_token"])) if metadata["userinfo_endpoint"]
232
+ value = normalize(claims)
233
+ current.set(SESSION_KEY, stored.merge(
234
+ "identity" => value, "access_token" => tokens["access_token"],
235
+ "refresh_token" => tokens["refresh_token"] || stored["refresh_token"],
236
+ "id_token" => tokens["id_token"] || stored["id_token"],
237
+ "expires_at" => Time.now.to_i + tokens.fetch("expires_in", 0).to_i
238
+ ))
239
+ value
240
+ rescue StandardError
241
+ current&.delete(SESSION_KEY)
242
+ raise
243
+ end
244
+
245
+ def logout(request_or_session, return_to = "/")
246
+ current = session(request_or_session)
247
+ stored = current&.get(SESSION_KEY)
248
+ current&.destroy
249
+ endpoint = discover["end_session_endpoint"]
250
+ target = @post_logout_redirect_uri || self.class.safe_return(return_to)
251
+ return target unless endpoint
252
+ params = { post_logout_redirect_uri: target, client_id: @client_id }
253
+ params[:id_token_hint] = stored["id_token"] if stored.is_a?(Hash) && stored["id_token"]
254
+ "#{endpoint}?#{URI.encode_www_form(params)}"
255
+ end
256
+
257
+ def self.mount_configured
258
+ return false if @mounted || !configured?
259
+ owned = [["GET", "/auth/login"], ["GET", "/auth/callback"], ["POST", "/auth/logout"]]
260
+ collisions = Tina4::Router.routes.select { |route| owned.include?([route.method, route.path]) }
261
+ unless collisions.empty?
262
+ raise SsoError, "SSO route collision: #{collisions.map { |route| "#{route.method} #{route.path}" }.join(', ')}"
263
+ end
264
+ sso = from_issuer
265
+ Tina4::Router.get("/auth/login") do |request, response|
266
+ response.redirect(sso.login(request, request.params["return_to"] || "/"))
267
+ end
268
+ Tina4::Router.get("/auth/callback") do |request, response|
269
+ begin
270
+ response.redirect(sso.callback(request)["return_to"])
271
+ rescue SsoError => e
272
+ response.json({ error: "SSO_CALLBACK_FAILED", message: e.message }, 400)
273
+ end
274
+ end
275
+ Tina4::Router.post("/auth/logout") do |request, response|
276
+ response.redirect(sso.logout(request, request.params["return_to"] || "/"))
277
+ end
278
+ @mounted = true
279
+ true
280
+ end
281
+ end
282
+
283
+ SSO = Sso
284
+ end
data/lib/tina4/swagger.rb CHANGED
@@ -161,6 +161,17 @@ module Tina4
161
161
  }
162
162
  end
163
163
 
164
+ sso_issuer = ENV["TINA4_SSO_ISSUER"].to_s.sub(%r{/$}, "")
165
+ unless sso_issuer.empty?
166
+ schemes["oidc"] = {
167
+ "type" => "openIdConnect",
168
+ "openIdConnectUrl" => "#{sso_issuer}/.well-known/openid-configuration"
169
+ }
170
+ schemes["ssoSession"] = {
171
+ "type" => "apiKey", "in" => "cookie", "name" => "tina4_session"
172
+ }
173
+ end
174
+
164
175
  # Registered schemes win (let an app override bearerAuth or add oauth2).
165
176
  @registered_schemes.each { |name, defn| schemes[name] = defn }
166
177
  schemes
@@ -302,7 +313,11 @@ module Tina4
302
313
  #
303
314
  # Both flags are honoured. A custom auth_handler protects a route even
304
315
  # when auth_required is false for its method, so it is still documented.
305
- return sanitize_security([{ default_scheme => [] }], schemes) if route.auth_required || route.auth_handler
316
+ if route.auth_required || route.auth_handler
317
+ requirements = [{ default_scheme => [] }]
318
+ requirements << { "ssoSession" => [] } if default_scheme == "bearerAuth" && schemes.key?("ssoSession")
319
+ return sanitize_security(requirements, schemes)
320
+ end
306
321
 
307
322
  nil
308
323
  end
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.103"
4
+ VERSION = "3.13.105"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -15,6 +15,7 @@ require_relative "tina4/database_adapter"
15
15
  require_relative "tina4/database_url"
16
16
  require_relative "tina4/database"
17
17
  require_relative "tina4/database_result"
18
+ require_relative "tina4/point"
18
19
  require_relative "tina4/field_types"
19
20
  require_relative "tina4/orm"
20
21
  require_relative "tina4/query_builder"
@@ -25,6 +26,7 @@ require_relative "tina4/template"
25
26
  require_relative "tina4/frond"
26
27
  require_relative "tina4/auth"
27
28
  require_relative "tina4/session"
29
+ require_relative "tina4/sso"
28
30
  require_relative "tina4/middleware"
29
31
  require_relative "tina4/cors"
30
32
  require_relative "tina4/rate_limiter"
@@ -487,6 +489,10 @@ module Tina4
487
489
  # Auto-discover routes
488
490
  auto_discover(root_dir)
489
491
 
492
+ # Configuration-first OIDC mounts after application discovery so a
493
+ # canonical-path collision fails loudly rather than being shadowed.
494
+ Tina4::Sso.mount_configured
495
+
490
496
  # Apply pending DB migrations on startup (non-breaking — see method doc).
491
497
  # Runs AFTER route discovery / DB bind, BEFORE serving.
492
498
  auto_migrate_on_startup!(root_dir)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.103
4
+ version: 3.13.105
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-16 00:00:00.000000000 Z
11
+ date: 2026-08-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -397,6 +397,7 @@ files:
397
397
  - lib/tina4/mqtt_message.rb
398
398
  - lib/tina4/orm.rb
399
399
  - lib/tina4/plan.rb
400
+ - lib/tina4/point.rb
400
401
  - lib/tina4/port_takeover.rb
401
402
  - lib/tina4/project_index.rb
402
403
  - lib/tina4/public/__feedback/widget.js
@@ -448,6 +449,7 @@ files:
448
449
  - lib/tina4/session_handlers/valkey_handler.rb
449
450
  - lib/tina4/shutdown.rb
450
451
  - lib/tina4/sql_translator.rb
452
+ - lib/tina4/sso.rb
451
453
  - lib/tina4/swagger.rb
452
454
  - lib/tina4/template.rb
453
455
  - lib/tina4/templates/base.twig