runlight 0.1.0 → 0.1.1

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.
@@ -18,7 +18,10 @@ module Runlight
18
18
  # - cronSecret: also accepted as a bearer token on POST /api/check. Defaults to CRON_SECRET.
19
19
  # - observeKey: lets another site report AI agent fetches to POST /api/observe. Defaults to RUNLIGHT_OBSERVE_KEY.
20
20
  # - signOut, signIn: links the dashboard shows. The standalone server sets them.
21
- # - accounts: true for sign-in accounts, or a Runlight::Accounts::Web of your own.
21
+ # - accounts: true for sign-in accounts, or a Runlight::Accounts::Web of your own. Sessions are signed with the
22
+ # Core's secret (RUNLIGHT_SECRET, else RUNLIGHT_TOKEN). With the token left nil and no secret, only
23
+ # development makes one up (a restart signs everyone out); anywhere else that raises ArgumentError, since
24
+ # each process of an app would make its own and sign the others' sessions out.
22
25
  # - geoCredit: credits DB-IP in the dashboard's footer.
23
26
  # - origin: the address people open the app at, such as https://example.com.
24
27
  # - ownHosts: a callable returning more names the dashboard is reached at.
@@ -73,9 +76,7 @@ module Runlight
73
76
  EMAIL = /\A[^#{Js::SPACE}@<>"]+@[^#{Js::SPACE}@<>"]+\.[^#{Js::SPACE}@<>"]+\z/
74
77
  # A dashboard's origin, which a picker ticket names.
75
78
  ORIGIN = %r{\Ahttps?://[^/?\##{Js::SPACE}]+\z}
76
- # An address a report's links may point back to.
77
- HOME = %r{\Ahttps?://[^#{Js::SPACE}]+\z}
78
- private_constant :EMAIL, :ORIGIN, :HOME
79
+ private_constant :EMAIL, :ORIGIN
79
80
 
80
81
  # The files in assets/, read once per process.
81
82
  @assets = {}
@@ -118,11 +119,17 @@ module Runlight
118
119
 
119
120
  # Accounts: the standalone server passes its own, and an app turns them on with true. Sessions need a secret
120
121
  # that outlives the process; in development without one, a made-up one does, so a restart signs everyone out.
121
- # An app left open on purpose (token: nil) is treated like development here.
122
+ # An app left open on purpose (token: nil) is treated like development for the first account, but outside
123
+ # development it still needs a real secret: TypeScript runs as one process, where a made-up one only lasts
124
+ # until a restart, but a Ruby app usually runs several, and each would sign the others' sessions out.
122
125
  token = @token
123
126
  open_setup = token.nil? || (token == "" && development?)
124
- account_secret = runlight.secret || (open_setup ? Hashing.random_id(32) : nil)
125
127
  accounts = options["accounts"]
128
+ if accounts == true && token.nil? && runlight.secret.to_s.empty? && !development?
129
+ raise ArgumentError, "Runlight: accounts need a secret every process shares; set RUNLIGHT_SECRET (or pass secret:) to a long random string"
130
+ end
131
+
132
+ account_secret = runlight.secret || (open_setup ? Hashing.random_id(32) : nil)
126
133
  @web = if accounts.is_a?(Accounts::Web)
127
134
  accounts
128
135
  elsif accounts == true && !account_secret.nil? && account_secret != ""
@@ -504,7 +511,7 @@ module Runlight
504
511
  extend Helpers
505
512
  include Helpers
506
513
 
507
- PUBLIC_HELPERS = %i[coded json json? host_name cookie_value read_cookie bearer normalise_base dashboard manage_path].freeze
514
+ PUBLIC_HELPERS = %i[coded json json? host_name cookie_value read_cookie bearer normalise_base dashboard manage_path oauth_document?].freeze
508
515
  private_constant :PUBLIC_HELPERS
509
516
  private(*Helpers.instance_methods(false))
510
517
  private_class_method(*(Helpers.instance_methods(false) - PUBLIC_HELPERS))
@@ -536,7 +543,8 @@ module Runlight
536
543
  end
537
544
  end
538
545
 
539
- # The routes as a Rack app. The answer to a HEAD has no body.
546
+ # The routes as a Rack app. The answer to a HEAD has no body. Once the answer is sent, the work left for
547
+ # later (a retention change's deletions) runs in the Core's idle, as RackApp runs it.
540
548
  def call(env)
541
549
  begin
542
550
  request = Http::Request.from_rack(env)
@@ -545,7 +553,12 @@ module Runlight
545
553
  [Json.encode({ "error" => "That request is too large" })]]
546
554
  end
547
555
  status, headers, body = handle(request).to_rack
548
- [status, headers, request.method == "HEAD" ? [] : body]
556
+ idle = lambda do
557
+ @rl.idle
558
+ rescue StandardError => e
559
+ warn("Runlight: #{e.message}")
560
+ end
561
+ [status, headers, RackApp::Body.new(request.method == "HEAD" ? [] : body, idle)]
549
562
  end
550
563
 
551
564
  private
@@ -764,6 +777,44 @@ module Runlight
764
777
  Http::Response.new(answer.text, status: answer.status, headers: back)
765
778
  end
766
779
 
780
+ # The checks a link domain passes before a site gets it, from Settings or from an import: the name as it
781
+ # is kept, or the refusal to send.
782
+ def check_link_domain(request, url, site, value)
783
+ domain = Js.lower(Js.trim(value))
784
+ domain = domain.sub(%r{\Ahttps?://}, "")
785
+ domain = domain.sub(%r{/.*\z}, "")
786
+ domain = domain.sub(/\.+\z/, "")
787
+ domain = domain.sub(/\Awww\./, "")
788
+ return coded("That is not a domain name", "domain_invalid", 400) unless domain.match?(DOMAIN_NAME)
789
+
790
+ if private_name?(domain) || Safefetch.resolves_privately?(domain)
791
+ return coded("#{domain} is not a public domain name. Use one that browsers anywhere can reach.", "domain_not_public", 400, { "domain" => domain })
792
+ end
793
+
794
+ # A link domain answers every path on it, so it must never be where the dashboard or a counted site lives.
795
+ # The request's own Host is the caller's to choose, so the configured address and the names people
796
+ # signed in from count too. A hub cannot know every name this app answers on, so it adds none until
797
+ # the app knows its own address.
798
+ return origin_needed if @managed.key?(request) && @origin.nil?
799
+
800
+ here = [request.headers.get("host"), request.headers.get("x-forwarded-host"), url.host].select { |h| Js.truthy?(h) }
801
+ own = [*(@origin.nil? ? [] : [Http::Url.new(@origin).host]), *here]
802
+ @options["ownHosts"]&.call&.each { |host| own << host.to_s }
803
+ taken = own.map { |h| host_name(h) }
804
+ sites.each do |s|
805
+ taken.concat(s["hostnames"])
806
+ taken.concat(@rl.remote(s["id"])&.[]("hostnames") || [])
807
+ end
808
+ if taken.include?(domain)
809
+ return coded("#{domain} is where this dashboard or one of your sites lives. Use a separate domain or subdomain for short links, such as go.#{domain}.", "domain_in_use", 400, { "domain" => domain })
810
+ end
811
+
812
+ owner = store.link_domains.find { |d| d["domain"] == domain }
813
+ return coded("#{domain} already belongs to another site", "domain_taken", 409, { "domain" => domain }) if !owner.nil? && owner["site"] != site
814
+
815
+ domain
816
+ end
817
+
767
818
  def links_api(request, path, url)
768
819
  @rl.init
769
820
  site = query_site(url)
@@ -778,41 +829,9 @@ module Runlight
778
829
  body = read_json(request)
779
830
  return body if body.is_a?(Http::Response)
780
831
 
781
- domain = Js.lower(Js.trim(text(body, "domain")))
782
- domain = domain.sub(%r{\Ahttps?://}, "")
783
- domain = domain.sub(%r{/.*\z}, "")
784
- domain = domain.sub(/\.+\z/, "")
785
- domain = domain.sub(/\Awww\./, "")
786
- return coded("That is not a domain name", "domain_invalid", 400) unless domain.match?(DOMAIN_NAME)
832
+ domain = check_link_domain(request, url, site["id"], text(body, "domain"))
833
+ return domain if domain.is_a?(Http::Response)
787
834
 
788
- if private_name?(domain) || Safefetch.resolves_privately?(domain)
789
- return coded("#{domain} is not a public domain name. Use one that browsers anywhere can reach.", "domain_not_public", 400, { "domain" => domain })
790
- end
791
-
792
- # A link domain answers every path on it, so it must never be where the dashboard or a counted site lives.
793
- # The request's own Host is the caller's to choose, so the configured address and the names people
794
- # signed in from count too. A hub cannot know every name this app answers on, so it adds none until
795
- # the app knows its own address.
796
- return origin_needed if @managed.key?(request) && @origin.nil?
797
-
798
- here = [request.headers.get("host"), request.headers.get("x-forwarded-host"), url.host].select { |h| Js.truthy?(h) }
799
- own = [*(@origin.nil? ? [] : [Http::Url.new(@origin).host]), *here]
800
- @options["ownHosts"]&.call&.each { |host| own << host.to_s }
801
- taken = own.map { |h| host_name(h) }
802
- sites.each do |s|
803
- taken.concat(s["hostnames"])
804
- taken.concat(@rl.remote(s["id"])&.[]("hostnames") || [])
805
- end
806
- if taken.include?(domain)
807
- return coded("#{domain} is where this dashboard or one of your sites lives. Use a separate domain or subdomain for short links, such as go.#{domain}.", "domain_in_use", 400, { "domain" => domain })
808
- end
809
-
810
- store.link_domains.each do |d|
811
- next unless d["domain"] == domain
812
- return coded("#{domain} already belongs to another site", "domain_taken", 409, { "domain" => domain }) if d["site"] != site["id"]
813
-
814
- break
815
- end
816
835
  store.add_link_domain(domain, site["id"], @rl.now)
817
836
  @rl.forget_link_domains
818
837
  return json({ "domain" => domain }, 201)
@@ -903,6 +922,11 @@ module Runlight
903
922
  credentials(Js.get(body, "credentials")),
904
923
  cursor.is_a?(String) ? cursor : nil,
905
924
  done.is_a?(Float) && done.nan? ? 0 : done,
925
+ # An imported link's domain passes the same checks as one added in Settings, or the link comes over without it.
926
+ lambda do |domain|
927
+ checked = check_link_domain(request, url, site["id"], domain)
928
+ checked.is_a?(Http::Response) ? nil : checked
929
+ end,
906
930
  )
907
931
  return json(step)
908
932
  rescue Importers::ImportError => e
@@ -1208,13 +1232,13 @@ module Runlight
1208
1232
  end
1209
1233
  return coded("A site can send to at most 50 addresses", "report_limit", 400) if existing.length >= 50
1210
1234
 
1211
- # Links in the email point back to the configured address, or else to this dashboard as the
1212
- # browser sees it. A report made from a hub needs the configured address, where its unsubscribe
1213
- # link answers, since the Host its request names is the hub's to choose.
1235
+ # Links in the email point back to the configured address, or else to this dashboard at the address
1236
+ # the request came to. An address in the body is never used, so whoever adds a report cannot point
1237
+ # its links somewhere else. A report made from a hub needs the configured address, where its
1238
+ # unsubscribe link answers, since the Host its request names is the hub's to choose.
1214
1239
  return origin_needed if @managed.key?(request) && @origin.nil?
1215
1240
 
1216
- given = @origin.nil? ? text(body, "origin") : ""
1217
- home = Js.scrub(given).match?(HOME) ? given.sub(%r{/+\z}, "") : "#{@origin || url.origin}#{@base}"
1241
+ home = "#{@origin || url.origin}#{@base}"
1218
1242
  # A period already due counts as sent, so a report added mid-week first goes out on the next Monday, as the form says.
1219
1243
  now = @rl.now
1220
1244
  due = Reports.last_period(frequency, now, site["timezone"])
@@ -1520,7 +1544,15 @@ module Runlight
1520
1544
  parsed = origin?(origin) && Js.length(origin) <= 200 ? Http::Url.parse(origin) : nil
1521
1545
  return coded("Send the admin page's origin, such as https://example.com", "embed_origin", 400) if parsed.nil? || parsed.origin != origin
1522
1546
 
1523
- host = host_name(parsed.host)
1547
+ # Only https and the default port, so a plain http page or another service on the same name cannot frame
1548
+ # the dashboard; a page on this computer, for development, may use http and any port. The name must be
1549
+ # one of the site's own, which are kept without www, so www. is the only difference allowed.
1550
+ local = ["localhost", "127.0.0.1", "[::1]"].include?(parsed.hostname)
1551
+ if !local && (parsed.protocol != "https:" || parsed.port != "")
1552
+ return coded("Send the admin page's origin, such as https://example.com", "embed_origin", 400)
1553
+ end
1554
+
1555
+ host = parsed.hostname.sub(/\Awww\./, "")
1524
1556
  domains = ((rl.remote(site["id"]) || {})["hostnames"] || site["hostnames"] || []).map { |h| host_name(h) }
1525
1557
  unless domains.include?(host)
1526
1558
  return coded("#{host} is not one of this site's domains. Add it to the site's domains in Runlight's settings.", "embed_host", 400, { "host" => host })
@@ -1621,7 +1653,7 @@ module Runlight
1621
1653
  return site if site.is_a?(Http::Response)
1622
1654
 
1623
1655
  begin
1624
- return json(Importers::Visits.import_csv_visits(rl, site["id"], plain(Js.get(body, "rows"))))
1656
+ return json(Importers::Visits.import_csv_visits(rl, site["id"], plain(Js.get(body, "rows")), Js.get(body, "secret")))
1625
1657
  rescue Importers::ImportError => e
1626
1658
  return refused(e, "import_failed")
1627
1659
  end
@@ -2153,7 +2185,19 @@ module Runlight
2153
2185
  return coded("Unknown site", "unknown_site", 404) if site.nil? || (!only.nil? && site["id"] != only)
2154
2186
 
2155
2187
  remote = rl.remote(site["id"])
2156
- return pass_through(remote, path, url, request) unless remote.nil?
2188
+ # A share link or an embedded dashboard sees how many are here now and what they read, but not the feed of
2189
+ # each visit, with its time and city, since anyone with the link could watch one person's way through the site.
2190
+ unfeed = ->(live) { shared.nil? ? live : live.merge("recent" => []) }
2191
+ unless remote.nil?
2192
+ answer = pass_through(remote, path, url, request)
2193
+ return answer if path != "/api/realtime" || shared.nil? || !answer.ok?
2194
+
2195
+ parsed, live = Js.parse_json(answer.text)
2196
+ return json(unfeed.call(live)) if parsed && live.is_a?(Hash)
2197
+
2198
+ host = Http::Url.new(remote["url"].to_s).host
2199
+ return coded("Could not reach #{host}", "unreachable", 502, { "host" => host })
2200
+ end
2157
2201
 
2158
2202
  if path == "/api/icon"
2159
2203
  host = site["hostnames"][0]
@@ -2170,7 +2214,7 @@ module Runlight
2170
2214
  })
2171
2215
  end
2172
2216
 
2173
- return json(store.realtime(site["id"], rl.now)) if path == "/api/realtime"
2217
+ return json(unfeed.call(store.realtime(site["id"], rl.now))) if path == "/api/realtime"
2174
2218
 
2175
2219
  read = read_query(url, site)
2176
2220
  return read if read.is_a?(Http::Response)
@@ -2284,10 +2328,12 @@ module Runlight
2284
2328
 
2285
2329
  keys = store.event_prop_keys(query, event)
2286
2330
  asked = params.get("key")
2287
- # A property name goes into a JSON path on SQLite, so quotes and backslashes are refused.
2288
- return coded("Bad property name", "property_bad", 400) if !asked.nil? && !asked.match?(/\A[^"\\]{1,64}\z/)
2331
+ # A property name goes into a JSON path on SQLite, so quotes and backslashes are refused, and without
2332
+ # one asked for, the most used name that has none is shown.
2333
+ safe = ->(name) { name.match?(/\A[^"\\]{1,64}\z/) }
2334
+ return coded("Bad property name", "property_bad", 400) if !asked.nil? && !safe.call(asked)
2289
2335
 
2290
- key = asked || keys.first&.[]("key")
2336
+ key = asked || keys.find { |k| safe.call(k["key"].to_s) }&.[]("key")
2291
2337
  count = limit(params.get("limit"), 100)
2292
2338
  rows = !key.nil? && key != "" ? store.event_prop_values(query, event, key, whole(count)) : []
2293
2339
  return json({ "site" => site["id"], "range" => range_out, "event" => event, "keys" => keys, "key" => key, "rows" => rows })
@@ -96,6 +96,41 @@ module Runlight
96
96
  true
97
97
  end
98
98
 
99
+ # Names clouds give their metadata services.
100
+ METADATA_NAMES = %w[metadata metadata.google.internal metadata.goog instance-data instance-data.ec2.internal].freeze
101
+
102
+ # Whether an address is link-local (169.254.0.0/16, fe80::/10) or a cloud's metadata service (fd00:ec2::254,
103
+ # Alibaba's 100.100.100.200), in any of its IPv6 spellings. A model's API or a mail server is never there,
104
+ # though it may well be on this computer or a private network, so those stay allowed where this is checked.
105
+ def metadata_address?(ip)
106
+ four = v4(ip)
107
+ g = four.nil? ? v6(ip) : nil
108
+ # IPv4 inside IPv6, mapped (::ffff:0:0/96), compatible (::/96), or NAT64 (64:ff9b::/96), is read as IPv4.
109
+ if four.nil? && !g.nil? && ((g[0, 5].all?(&:zero?) && (g[5] == 0xffff || g[5].zero?)) || (g[0] == 0x64 && g[1] == 0xff9b && g[2, 4].all?(&:zero?)))
110
+ four = [g[6] >> 8, g[6] & 255, g[7] >> 8, g[7] & 255]
111
+ end
112
+ return (four[0] == 169 && four[1] == 254) || four == [100, 100, 100, 200] unless four.nil?
113
+ return false if g.nil?
114
+ return true if (g[0] & 0xffc0) == 0xfe80
115
+
116
+ g[0] == 0xfd00 && g[1] == 0xec2 && g[2, 5].all?(&:zero?) && g[7] == 0x254
117
+ end
118
+
119
+ # Whether a host, by its name, its address, or any address its name resolves to now, is link-local or a
120
+ # metadata service. A name that does not resolve here is let through, since the connection then goes
121
+ # nowhere such a check could have stopped.
122
+ def metadata_host?(host)
123
+ name = Js.lower(Js.trim(host)).sub(/\A\[/, "").sub(/\]\z/, "").sub(/\.+\z/, "")
124
+ return true if METADATA_NAMES.include?(name) || metadata_address?(name)
125
+ return false if !v4(name).nil? || !v6(name).nil?
126
+
127
+ begin
128
+ Safefetch.resolver.call(name).any? { |address| metadata_address?(address.to_s) }
129
+ rescue StandardError
130
+ false
131
+ end
132
+ end
133
+
99
134
  # Every address a name resolves to, v4 and v6, as getaddrinfo() gives them (the hosts file included).
100
135
  # Empty when it does not resolve.
101
136
  def lookup(name)
@@ -4,7 +4,7 @@ require "json"
4
4
 
5
5
  module Runlight
6
6
  # The gem's own version, which the release keeps equal to the npm package's.
7
- GEM_VERSION = "0.1.0"
7
+ GEM_VERSION = "0.1.1"
8
8
 
9
9
  # The SDK's version and the HTTP API's, read from assets/build.json, which
10
10
  # scripts/ruby-assets.mts writes from the TypeScript SDK, so the two always
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: runlight
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon C. Phillips
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-10 00:00:00.000000000 Z
11
+ date: 2026-10-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord