runlight 0.1.0 → 0.1.3

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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/assets/build.json +4 -4
  3. data/assets/dashboard.css +1 -1
  4. data/assets/dashboard.js +71 -22
  5. data/assets/locales.json +5 -5
  6. data/assets/pick-return.js +1 -0
  7. data/assets/picker.js +1 -1
  8. data/assets/tracker.js +1 -1
  9. data/lib/runlight/accounts/accounts.rb +90 -19
  10. data/lib/runlight/accounts/throttle.rb +123 -38
  11. data/lib/runlight/accounts/web.rb +50 -14
  12. data/lib/runlight/assistant.rb +40 -8
  13. data/lib/runlight/connect.rb +2 -3
  14. data/lib/runlight/core.rb +148 -34
  15. data/lib/runlight/dates.rb +31 -8
  16. data/lib/runlight/db/connect.rb +32 -6
  17. data/lib/runlight/db/db.rb +129 -9
  18. data/lib/runlight/db/prefixed.rb +92 -0
  19. data/lib/runlight/geo.rb +4 -3
  20. data/lib/runlight/http/fetcher.rb +31 -6
  21. data/lib/runlight/http/request.rb +14 -3
  22. data/lib/runlight/importers/csv_visits.rb +1 -1
  23. data/lib/runlight/importers/index.rb +10 -2
  24. data/lib/runlight/importers/visits.rb +17 -6
  25. data/lib/runlight/importers/write.rb +21 -6
  26. data/lib/runlight/links.rb +14 -9
  27. data/lib/runlight/mail/ses.rb +3 -2
  28. data/lib/runlight/mail/smtp.rb +2 -0
  29. data/lib/runlight/mail/smtp_session.rb +22 -1
  30. data/lib/runlight/mail/transports.rb +19 -2
  31. data/lib/runlight/mcp.rb +8 -1
  32. data/lib/runlight/metadata_address_error.rb +13 -0
  33. data/lib/runlight/oauth.rb +85 -18
  34. data/lib/runlight/rack_app.rb +64 -10
  35. data/lib/runlight/redirect_refused_error.rb +14 -0
  36. data/lib/runlight/routes.rb +306 -144
  37. data/lib/runlight/safefetch.rb +133 -1
  38. data/lib/runlight/server/agents.rb +1 -1
  39. data/lib/runlight/server/cli.rb +1 -1
  40. data/lib/runlight/server/config.rb +64 -19
  41. data/lib/runlight/server/standalone.rb +7 -6
  42. data/lib/runlight/store/sql.rb +10 -0
  43. data/lib/runlight/store/sql_store.rb +75 -13
  44. data/lib/runlight/stores.rb +19 -15
  45. data/lib/runlight/tables_busy_error.rb +11 -0
  46. data/lib/runlight/version.rb +1 -1
  47. data/lib/runlight.rb +4 -0
  48. metadata +7 -2
@@ -162,11 +162,13 @@ module Runlight
162
162
  store.db.run("DELETE FROM rl_events WHERE site = ? AND ts >= ? AND ts < ? AND kind IN ('pageview', 'event') AND session IN (#{imported})",
163
163
  [site_id, from, to, site_id])
164
164
  # Visits of these days that kept no rows go too. Their rows would come within EVENT_TAIL_MS of the step,
165
- # so the time bounds let the (site, ts) index find them, with no scan of every event.
165
+ # so the time bounds let the (site, ts) index find them, with no scan of every event. That reaches back
166
+ # EVENT_TAIL_MS too, for a visit an earlier import began before this step whose rows were all in it: a
167
+ # CSV imported again has new ids, so its new visit does not take that one's place.
166
168
  store.db.run(
167
169
  "DELETE FROM rl_sessions WHERE site = ? AND imported = 1 AND started_at >= ? AND started_at < ?
168
170
  AND id NOT IN (SELECT e.session FROM rl_events e WHERE e.site = ? AND e.ts >= ? AND e.ts < ?)",
169
- [site_id, from, to, site_id, from, to + tail],
171
+ [site_id, from - tail, to, site_id, from - tail, to + tail],
170
172
  )
171
173
  hits.each do |h|
172
174
  counts["visits"] += 1 if write_event(store, site, h["ns"], h["hit"])
@@ -336,8 +338,15 @@ module Runlight
336
338
  # One batch of a CSV file, sorted oldest first by the dashboard. As with Umami, only rows from before
337
339
  # Runlight's own first visit, and within what the site keeps, are written. A batch can run again: its
338
340
  # time span is cleared first, so batches must not share a moment, which the dashboard sees to.
339
- # Returns { "pageviews", "events", "visits", "skipped" }.
340
- def import_csv_visits(runlight, site_id, rows)
341
+ #
342
+ # A CSV's visitor column can hold anything, an email or an IP address among them, and a plain hash of
343
+ # one is easily undone by trying every address. So each key is an HMAC under a random secret made for
344
+ # the import and never stored: the first batch gets one, the answer carries it, and the caller sends
345
+ # it with every later batch of the same file, so a visit that runs across two batches stays one. A file
346
+ # imported again gets a new secret and new ids; its time span is cleared first, as always, so nothing is
347
+ # counted twice.
348
+ # Returns { "pageviews", "events", "visits", "skipped", "secret" }.
349
+ def import_csv_visits(runlight, site_id, rows, secret = nil)
341
350
  runlight.init
342
351
  raise ImportError.new("Unknown site", "unknown_site") if runlight.site(site_id).nil?
343
352
  if !rows.is_a?(Array) || rows.length > CsvVisits::CSV_BATCH
@@ -356,6 +365,7 @@ module Runlight
356
365
  format = CsvVisits.csv_format(clean.empty? ? [] : clean[0].keys)
357
366
  raise ImportError.new("This CSV is not an Umami export or Runlight's visit format", "import_csv_format") if format.nil?
358
367
 
368
+ key = secret.is_a?(String) && secret.match?(/\A[A-Za-z0-9_-]{22,128}\z/) ? secret : Accounts::Crypto.base64url(Accounts::Crypto.random_bytes(24))
359
369
  cutoff = runlight.retention_cutoff(site_id) || 0
360
370
  own = runlight.store.first_own_visit(site_id)
361
371
  finish = [own.nil? ? Float::INFINITY : own, runlight.now].min
@@ -369,10 +379,11 @@ module Runlight
369
379
  end
370
380
  hits = hits.each_with_index.sort_by { |h, i| [h["hit"]["ts"], i] }.map(&:first)
371
381
  skipped = clean.length - hits.length
372
- return { "pageviews" => 0, "events" => 0, "visits" => 0, "skipped" => skipped } if hits.empty?
382
+ return { "pageviews" => 0, "events" => 0, "visits" => 0, "skipped" => skipped, "secret" => key } if hits.empty?
373
383
 
384
+ hits.each { |h| h["hit"]["key"] = Accounts::Crypto.base64url(Accounts::Crypto.hmac("SHA-256", key, h["hit"]["key"])) }
374
385
  counts = write_step(runlight, site_id, hits[0]["hit"]["ts"], hits[-1]["hit"]["ts"] + 1, hits)
375
- counts.merge("skipped" => skipped)
386
+ counts.merge("skipped" => skipped, "secret" => key)
376
387
  end
377
388
 
378
389
  private_class_method :progress_key, :all, :write_step, :referrer_of, :from_umami, :write_event
@@ -6,7 +6,9 @@ module Runlight
6
6
  module Write
7
7
  # Domains run by the shorteners themselves. Links there stay on Runlight's own path.
8
8
  SHORTENER_DOMAINS = %w[bit.ly bitly.com j.mp dub.sh dub.co dub.link short.gy rebrand.ly rebrandly.com rb.gy].freeze
9
- private_constant :SHORTENER_DOMAINS
9
+ # The most clicks a day one imported link gets from a daily count.
10
+ MAX_DAILY = 10_000
11
+ private_constant :SHORTENER_DOMAINS, :MAX_DAILY
10
12
 
11
13
  # Browser and system names as other tools write them, in Runlight's spelling.
12
14
  BROWSERS = {
@@ -76,8 +78,10 @@ module Runlight
76
78
  # importing again skips what is already there.
77
79
  #
78
80
  # foreign: { "sourceId", "slug", "domain", "name", "url", "createdAt" }; history: { "clicks"?, "daily"? }.
81
+ # link_domain: given a branded domain, the domain as the site keeps it once it passes a link domain's checks,
82
+ # or nil to bring the link over without it.
79
83
  # Returns { "status", "clicks", "reason"?, "code"?, "params"? }.
80
- def write_link(runlight, site, source, foreign, history)
84
+ def write_link(runlight, site, source, foreign, history, link_domain = ->(_domain) {})
81
85
  source_id = Js.string(foreign["sourceId"])
82
86
  id = imported_link_id(source, source_id)
83
87
  return { "status" => "skipped", "clicks" => 0 } unless runlight.store.link_by_id(id).nil?
@@ -96,8 +100,18 @@ module Runlight
96
100
  "params" => { "slug" => slug } }
97
101
  end
98
102
 
99
- domain = Sources.strip_www(Js.truthy?(foreign["domain"]) ? foreign["domain"].to_s : "")
100
- domain = "" if SHORTENER_DOMAINS.include?(domain)
103
+ # The destination passes the checks a link made by hand does.
104
+ begin
105
+ destination = Links.clean_url(foreign["url"])
106
+ rescue LinkError => e
107
+ return { "status" => "failed", "clicks" => 0, "reason" => e.message, "code" => e.code, "params" => e.params }
108
+ end
109
+
110
+ domain = Sources.strip_www(Js.lower(Js.truthy?(foreign["domain"]) ? foreign["domain"].to_s : ""))
111
+ # A branded domain must pass the checks one added in Settings does, so an import can never claim the
112
+ # dashboard's own host or a name inside a private network. One that fails is left off: the link is
113
+ # served from the app's own path instead.
114
+ domain = domain.empty? || SHORTENER_DOMAINS.include?(domain) ? "" : (link_domain.call(domain) || "")
101
115
  now = runlight.now
102
116
  clicks = 0
103
117
  # Nothing in the transaction is one link's own problem (those are checked above),
@@ -173,7 +187,8 @@ module Runlight
173
187
  start = Client.parse_date("#{d["day"]}T00:00:00Z")
174
188
  next if (start.is_a?(Float) && !start.finite?) || !(d["clicks"].is_a?(Numeric) && d["clicks"].positive?)
175
189
 
176
- n = [d["clicks"], 1_000_000].min
190
+ # A day's count is capped, so one made-up figure from a source cannot write millions of rows.
191
+ n = [d["clicks"], MAX_DAILY].min.floor
177
192
  i = 0
178
193
  while i < n
179
194
  store.insert_event({
@@ -194,7 +209,7 @@ module Runlight
194
209
  "domain" => domain,
195
210
  "slug" => slug,
196
211
  "name" => Js.slice(name, 0, 100),
197
- "url" => foreign["url"],
212
+ "url" => destination,
198
213
  "createdAt" => created,
199
214
  "updatedAt" => created,
200
215
  })
@@ -21,6 +21,19 @@ module Runlight
21
21
  SecureRandom.random_bytes(6).bytes.map { |byte| ALPHABET[byte % ALPHABET.length] }.join
22
22
  end
23
23
 
24
+ # A link's destination as it is kept: a full http or https URL, up to 2,000 characters.
25
+ def self.clean_url(value)
26
+ text = Js.trim(Js.string(value.nil? ? "" : value))
27
+ url = Http::Url.parse(text)
28
+ raise LinkError.new("The destination must be a full URL, starting with https://", "link_url") if url.nil?
29
+ if url.protocol != "https:" && url.protocol != "http:"
30
+ raise LinkError.new("The destination must start with http:// or https://", "link_protocol")
31
+ end
32
+ raise LinkError.new("The destination is longer than 2,000 characters", "link_long") if Js.length(text) > 2000
33
+
34
+ url.href
35
+ end
36
+
24
37
  def create(site, input)
25
38
  input = Options.normalize(input)
26
39
  @runlight.init
@@ -109,15 +122,7 @@ module Runlight
109
122
  private
110
123
 
111
124
  def clean_url(value)
112
- text = Js.trim(Js.string(value.nil? ? "" : value))
113
- url = Http::Url.parse(text)
114
- raise LinkError.new("The destination must be a full URL, starting with https://", "link_url") if url.nil?
115
- if url.protocol != "https:" && url.protocol != "http:"
116
- raise LinkError.new("The destination must start with http:// or https://", "link_protocol")
117
- end
118
- raise LinkError.new("The destination is longer than 2,000 characters", "link_long") if Js.length(text) > 2000
119
-
120
- url.href
125
+ self.class.clean_url(value)
121
126
  end
122
127
 
123
128
  def default_name(url)
@@ -79,13 +79,14 @@ module Runlight
79
79
  })
80
80
  headers.delete("host")
81
81
  begin
82
- response = (fetcher || Http::NetFetcher.new).fetch(url.href, { "method" => "POST", "headers" => headers, "body" => body, "timeoutMs" => 20_000 })
82
+ response = (fetcher || Http::NetFetcher.new).fetch(url.href, { "method" => "POST", "headers" => headers, "body" => body, "timeoutMs" => 20_000,
83
+ "maxBytes" => Transports::MAX_ERROR_BYTES + 1, "truncate" => true })
83
84
  rescue Http::FetchError => e
84
85
  raise MailError.new("Could not reach Amazon SES: #{e.message}", "mail_unreachable", { "host" => "Amazon SES", "detail" => e.message })
85
86
  end
86
87
  return if response.ok?
87
88
 
88
- message = Transports.service_message(response.text)
89
+ message = Transports.service_message(Transports.error_text(response))
89
90
  raise MailError.new("Amazon SES answered #{response.status}#{message.empty? ? "" : ": #{message}"}", "mail_refused",
90
91
  { "host" => "Amazon SES", "detail" => "#{response.status}#{message.empty? ? "" : " #{message}"}" })
91
92
  end
@@ -83,6 +83,8 @@ module Runlight
83
83
  s.connect(security == "tls", REPLY_TIMEOUT_MS)
84
84
  expect = lambda do |codes, what|
85
85
  reply = s.next_reply(REPLY_TIMEOUT_MS)
86
+ # Something that is not a mail server gets no more than that said, so its own words are never passed on.
87
+ raise MailError, "SMTP #{what}: #{s.host}:#{s.port} did not answer as a mail server does" unless reply["code"].between?(200, 599)
86
88
  raise MailError, Transports.slice16("SMTP #{what}: #{reply["code"]} #{reply["text"]}", 300) unless codes.include?(reply["code"])
87
89
 
88
90
  reply
@@ -9,6 +9,8 @@ module Runlight
9
9
  # (multi-line included, one at a time), and the whole send's deadline, which
10
10
  # every wait is held to.
11
11
  class SmtpSession
12
+ attr_reader :host, :port
13
+
12
14
  # deadline: nanoseconds on Smtp.monotonic's clock.
13
15
  def initialize(host, port, deadline, late_message)
14
16
  @host = host
@@ -20,12 +22,22 @@ module Runlight
20
22
  @lines = []
21
23
  end
22
24
 
25
+ # A mail server may be on this computer or a private network, but never at a link-local or cloud metadata
26
+ # address. The name is resolved here and the connection goes to the addresses checked, so it cannot
27
+ # answer differently a moment later.
23
28
  def connect(tls, timeout_ms)
24
29
  wait = [timeout_ms, left].min
25
30
  raise late if wait <= 0
26
31
 
32
+ bare = @host.sub(/\A\[/, "").sub(/\]\z/, "")
33
+ addresses = Safefetch.metadata_address?(bare) ? [bare] : Safefetch.lookup(bare)
34
+ if addresses.any? { |address| Safefetch.metadata_address?(address) }
35
+ raise MailError.new("SMTP: #{@host} is a link-local or cloud metadata address, which Runlight never connects to", "address_refused", { "host" => @host })
36
+ end
37
+
27
38
  begin
28
- socket = Socket.tcp(@host, @port, connect_timeout: wait / 1000.0)
39
+ # A name that does not resolve is still tried by name, for the resolver's own words.
40
+ socket = open_socket(addresses.empty? ? [@host] : addresses, wait)
29
41
  rescue SystemCallError, SocketError, IO::TimeoutError => e
30
42
  raise late if left <= 0
31
43
 
@@ -192,6 +204,15 @@ module Runlight
192
204
  @socket = ssl
193
205
  end
194
206
 
207
+ # A connection to the first of the addresses that answers, each tried in the time left.
208
+ def open_socket(targets, wait)
209
+ targets.each_with_index do |target, i|
210
+ return Socket.tcp(target, @port, connect_timeout: [wait, left].min / 1000.0)
211
+ rescue SystemCallError, SocketError, IO::TimeoutError
212
+ raise if i == targets.length - 1 || left <= 0
213
+ end
214
+ end
215
+
195
216
  def connect_detail(error, whole_wait)
196
217
  return(whole_wait ? "timed out" : "connection failed") if error.is_a?(IO::TimeoutError) || error.is_a?(Errno::ETIMEDOUT)
197
218
 
@@ -81,17 +81,34 @@ module Runlight
81
81
  ""
82
82
  end
83
83
 
84
+ # The most of a service's error read, so an answer without end never fills memory. One byte more is asked of
85
+ # the fetcher, which hands back what it read, so that an answer past the limit is seen as such and read as empty.
86
+ MAX_ERROR_BYTES = 65_536
87
+
88
+ # A service's error text, or none when it runs past MAX_ERROR_BYTES.
89
+ def error_text(response)
90
+ Body.read_text_capped(response, MAX_ERROR_BYTES)
91
+ rescue Http::BodyTooLong
92
+ ""
93
+ end
94
+
84
95
  # POSTs to a service, with the errors the dashboard shows. init: "headers" and "body".
85
96
  def post(fetcher, url, init, explains = true)
86
97
  begin
87
- response = fetcher.fetch(url, { "method" => "POST", "headers" => init["headers"], "body" => init["body"], "timeoutMs" => 20_000 })
98
+ # A webhook can be at any address, so it and each redirect are checked, as for every service.
99
+ response = Safefetch.service_fetch(url, { "method" => "POST", "headers" => init["headers"], "body" => init["body"], "timeoutMs" => 20_000,
100
+ "maxBytes" => MAX_ERROR_BYTES + 1, "truncate" => true }, fetcher)
101
+ rescue MetadataAddressError => e
102
+ raise MailError.new(e.message, "address_refused", { "host" => e.host })
103
+ rescue RedirectRefusedError => e
104
+ raise MailError.new(e.message, "redirect_refused", { "host" => e.host, "to" => e.to })
88
105
  rescue Http::FetchError => e
89
106
  host = Http::Url.new(url).host
90
107
  raise MailError.new("Could not reach #{host}: #{e.message}", "mail_unreachable", { "host" => host, "detail" => e.message })
91
108
  end
92
109
  return if response.ok?
93
110
 
94
- message = explains ? service_message(response.text) : ""
111
+ message = explains ? service_message(error_text(response)) : ""
95
112
  host = Http::Url.new(url).host
96
113
  raise MailError.new("#{host} answered #{response.status}#{message.empty? ? "" : ": #{message}"}", "mail_refused",
97
114
  { "host" => host, "detail" => "#{response.status}#{message.empty? ? "" : " #{message}"}" })
data/lib/runlight/mcp.rb CHANGED
@@ -28,6 +28,8 @@ module Runlight
28
28
  ].freeze
29
29
  # query.ts's MAX_FILTERS.
30
30
  MAX_FILTERS = 6
31
+ # The most messages one batch may hold.
32
+ MAX_BATCH = 20
31
33
 
32
34
  RANGE_KEYS = %w[site period from to filters].freeze
33
35
  COMPARE_KEYS = [*RANGE_KEYS, "compare", "compare_from", "compare_to"].freeze
@@ -321,8 +323,13 @@ module Runlight
321
323
  parsed, body = Js.parse_json(request.text)
322
324
  return Http::Response.new(Json.encode(rpc_error(nil, -32_700, "Send a JSON-RPC message")), status: 400, headers: headers) if !parsed || !Js.object?(body)
323
325
 
324
- # Batches were in the 2025-03-26 protocol; answering them costs nothing.
326
+ # Batches were in the 2025-03-26 protocol. Only a short list, so one request cannot start hundreds of
327
+ # reports; they run one after another here, where TypeScript runs four at a time.
325
328
  if body.is_a?(Array)
329
+ if body.length > MAX_BATCH
330
+ return Http::Response.new(Json.encode(rpc_error(nil, -32_600, "Send at most #{MAX_BATCH} messages in a batch")), status: 400, headers: headers)
331
+ end
332
+
326
333
  answers = body.map { |message| answer(message, read_api) }.compact
327
334
  return answers.empty? ? Http::Response.new("", status: 202) : Http::Response.new(Json.encode(answers), status: 200, headers: headers)
328
335
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runlight
4
+ # Refused before anything was fetched, because the address is link-local or a cloud's metadata service.
5
+ class MetadataAddressError < RuntimeError
6
+ attr_reader :host
7
+
8
+ def initialize(host)
9
+ @host = host
10
+ super("#{host} is a link-local or cloud metadata address, which Runlight never connects to")
11
+ end
12
+ end
13
+ end
@@ -16,7 +16,10 @@ module Runlight
16
16
  # The token is an ordinary API token, so it appears in Settings, API and AI,
17
17
  # beside the others, and deleting it there disconnects the app. It reads
18
18
  # stats, or with the "manage" scope (asked for by a Runlight hub) it also
19
- # changes one site's settings.
19
+ # changes one site's settings. With the "cms" scope (asked for by the
20
+ # WordPress, Drupal, and Craft plugins) it is the site's dashboard key, which
21
+ # only opens its read-only dashboard in the CMS, and the answer also carries
22
+ # the site's agents key, so connecting fills in the plugin's settings.
20
23
  #
21
24
  # The context the routes pass is a Hash: "runlight", "base", "isOwner" (callable(request) giving a bool), and
22
25
  # optionally "signIn" (String), "isReader" (callable(request) giving a bool), "accountOf" (callable(request)
@@ -29,6 +32,7 @@ module Runlight
29
32
  REGISTRATIONS_PER_MINUTE = 10
30
33
  # The longest client id, which carries the app's name and redirect addresses.
31
34
  MAX_CLIENT_ID = 2048
35
+ SCOPES = %w[read manage cms].freeze
32
36
 
33
37
  # Where the per-address count of registrations is kept. TypeScript keeps it in memory per install; here it
34
38
  # lives in the install's settings, keyed by a hash of the address, so every process shares it.
@@ -41,7 +45,7 @@ module Runlight
41
45
  }.freeze
42
46
 
43
47
  ESCAPES = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", '"' => "&quot;", "'" => "&#39;" }.freeze
44
- private_constant :REGISTRATIONS, :CORS, :ESCAPES
48
+ private_constant :REGISTRATIONS, :CORS, :ESCAPES, :SCOPES
45
49
 
46
50
  module_function
47
51
 
@@ -50,6 +54,16 @@ module Runlight
50
54
  base64url(OpenSSL::Digest::SHA256.digest(verifier))
51
55
  end
52
56
 
57
+ # The key a CMS plugin reports AI agents reading a site's pages with: made on first ask, or a new one that replaces it.
58
+ def agents_key(runlight, site, replace = false)
59
+ name = "observe-key:#{site}"
60
+ return runlight.store.setting_or_add(name) { "rlo_#{Hashing.random_id(20)}" } unless replace
61
+
62
+ key = "rlo_#{Hashing.random_id(20)}"
63
+ runlight.store.set_setting(name, key)
64
+ key
65
+ end
66
+
53
67
  # The URL that a 401 from the MCP endpoint points clients at, to start OAuth.
54
68
  def resource_metadata_url(origin, base)
55
69
  "#{origin}#{base}/.well-known/oauth-protected-resource"
@@ -71,7 +85,7 @@ module Runlight
71
85
  end
72
86
 
73
87
  if known.start_with?("/.well-known/oauth-protected-resource")
74
- return json({ "resource" => "#{issuer}/mcp", "authorization_servers" => [issuer], "scopes_supported" => %w[read manage], "bearer_methods_supported" => ["header"] })
88
+ return json({ "resource" => "#{issuer}/mcp", "authorization_servers" => [issuer], "scopes_supported" => SCOPES, "bearer_methods_supported" => ["header"] })
75
89
  end
76
90
  if known.start_with?("/.well-known/oauth-authorization-server") || known.start_with?("/.well-known/openid-configuration")
77
91
  return json({
@@ -83,7 +97,7 @@ module Runlight
83
97
  "grant_types_supported" => ["authorization_code"],
84
98
  "code_challenge_methods_supported" => ["S256"],
85
99
  "token_endpoint_auth_methods_supported" => ["none"],
86
- "scopes_supported" => %w[read manage],
100
+ "scopes_supported" => SCOPES,
87
101
  })
88
102
  end
89
103
 
@@ -141,8 +155,15 @@ module Runlight
141
155
  return refuse.call({ "error" => "invalid_request", "error_description" => "PKCE with S256 is required" })
142
156
  end
143
157
 
144
- manage = (form.get("scope") || "").split(/[#{Js::SPACE}]+/o).include?("manage")
158
+ scopes = (form.get("scope") || "").split(/[#{Js::SPACE}]+/o)
159
+ cms = scopes.include?("cms")
160
+ manage = !cms && scopes.include?("manage")
145
161
  name = client["name"].to_s
162
+ # A CMS plugin is for the site it runs on: one of the sites counting its admin's domain, or a new one for it.
163
+ host = site_host(redirect)
164
+ # As the tracker counts: a site that lists the domain, or the only site here when it lists none, which counts any.
165
+ local = runlight.sites.select { |s| runlight.remote(s["id"]).nil? }
166
+ counting = local.length == 1 && local[0]["hostnames"].empty? ? local : local.select { |s| s["hostnames"].include?(host) }
146
167
 
147
168
  unless ctx["isOwner"].call(request)
148
169
  # Someone signed in who may only read would be sent to sign in again and again.
@@ -169,6 +190,27 @@ module Runlight
169
190
  end
170
191
  # The app names itself, so the page also shows where the answer goes, which it cannot fake.
171
192
  sends_to = "<p class=\"note\">Allowing sends you back to <strong>#{esc(Http::Url.new(redirect).host)}</strong>. Only allow it if you started connecting there.</p>"
193
+ if cms
194
+ if counting.empty? && !runlight.managed_sites
195
+ return page("Add this site first",
196
+ "<p>No site in this Runlight counts <strong>#{esc(host)}</strong>. Add it to a site's domains in your code, then connect again from #{esc(name)}.</p>", 400)
197
+ end
198
+
199
+ choices = +""
200
+ counting.each { |s| choices << "<option value=\"#{esc(s["id"])}\">#{esc(s["name"])}</option>" }
201
+ choices << "<option value=\"new\">A new site for #{esc(host)}</option>" if runlight.managed_sites && counting.empty?
202
+ return page(
203
+ "Connect #{esc(name)}",
204
+ "<p><strong>#{esc(name)}</strong> wants to count <strong>#{esc(host)}</strong> in this Runlight.</p>\n" \
205
+ "<p>It will get the site's agents key, to report AI agents such as ChatGPT and Claude reading its pages, and a dashboard key, to show the site's " \
206
+ "read-only dashboard in its admin pages. It cannot change anything here or read other sites.</p>\n" \
207
+ "#{sends_to}\n" \
208
+ "<form method=\"post\" action=\"#{esc(base)}/oauth/authorize\">#{hidden}\n" \
209
+ "<label>Site<select name=\"site\">#{choices}</select></label>\n" \
210
+ "<p class=\"note\">Its dashboard key appears in Settings, API and AI, where deleting it stops the dashboard in #{esc(name)}.</p>\n" \
211
+ '<div class="buttons"><button type="submit" name="decision" value="deny" class="ghost">Deny</button><button type="submit" name="decision" value="allow">Allow</button></div></form>',
212
+ )
213
+ end
172
214
  sites = runlight.sites
173
215
  if manage
174
216
  # Changing settings is for one site at a time, so there is no "every site" here.
@@ -209,6 +251,22 @@ module Runlight
209
251
  return back.call({ "error" => "access_denied" }) if form.get("decision") != "allow"
210
252
 
211
253
  site = form.get("site") || ""
254
+ if cms
255
+ if site == "new"
256
+ begin
257
+ site = if !counting.empty? then counting[0]["id"]
258
+ elsif runlight.managed_sites then runlight.add_site({ "hostnames" => [host] })["id"]
259
+ else ""
260
+ end
261
+ rescue RangeError => e
262
+ # Such as an address, which a site cannot count by: there is no new site to make.
263
+ return back.call({ "error" => "invalid_request", "error_description" => e.message })
264
+ end
265
+ elsif counting.none? { |s| s["id"] == site }
266
+ site = ""
267
+ end
268
+ return back.call({ "error" => "invalid_request", "error_description" => "Pick a site that counts #{host}" }) if site == ""
269
+ end
212
270
  return back.call({ "error" => "invalid_request", "error_description" => "Unknown site" }) if site != "" && runlight.site(site).nil?
213
271
  if manage && (site == "" || !runlight.remote(site).nil?)
214
272
  return back.call({ "error" => "invalid_request", "error_description" => "Pick the site to manage" })
@@ -216,7 +274,7 @@ module Runlight
216
274
 
217
275
  code = Hashing.random_id(32)
218
276
  by = ctx["accountOf"]&.call(request)
219
- grant = { "client" => client_id, "redirect" => redirect, "challenge" => challenge, "site" => site, "scope" => manage ? "manage" : "read",
277
+ grant = { "client" => client_id, "redirect" => redirect, "challenge" => challenge, "site" => site, "scope" => cms ? "cms" : manage ? "manage" : "read",
220
278
  "expires" => runlight.now + CODE_MS }
221
279
  grant["by"] = by if !by.nil? && by != ""
222
280
  runlight.store.set_setting("oauth-code:#{Hashing.sha256(code)}", Json.encode(grant))
@@ -230,9 +288,8 @@ module Runlight
230
288
  return oauth_error("unsupported_grant_type", "Only authorization_code is supported") if form.get("grant_type") != "authorization_code"
231
289
 
232
290
  key = "oauth-code:#{Hashing.sha256(form.get("code") || "")}"
233
- stored = runlight.store.setting(key)
234
- # A code works once: it is gone before anything else is checked.
235
- runlight.store.set_setting(key, nil) if !stored.nil? && stored != ""
291
+ # A code works once: it is gone before anything else is checked, and of two swaps at once only one gets it.
292
+ stored = runlight.store.take_setting(key)
236
293
  grant = !stored.nil? && stored != "" ? Json.decode(stored) : nil
237
294
  return oauth_error("invalid_grant", "The code has expired or was already used") if grant.nil? || grant["expires"] < runlight.now
238
295
  if grant["client"] != form.get("client_id") || grant["redirect"] != form.get("redirect_uri")
@@ -249,12 +306,16 @@ module Runlight
249
306
  runlight.store.set_setting(found["usedKey"], stored_client ? Json.encode(used) : runlight.now.to_s)
250
307
  end
251
308
  secret = "rl_#{Hashing.random_id(20)}"
252
- scope = grant["scope"] == "manage" ? "manage" : "read"
309
+ scope = if grant["scope"] == "cms" then "cms"
310
+ elsif grant["scope"] == "manage" then "manage"
311
+ else "read"
312
+ end
313
+ # A CMS plugin's token is the site's dashboard key, which only opens its read-only dashboard there.
253
314
  row = {
254
315
  "id" => Hashing.random_id,
255
316
  "name" => Js.slice("#{Js.string(client["name"].nil? ? "An app" : client["name"])} (OAuth)", 0, 100),
256
317
  "site" => grant["site"].to_s,
257
- "scope" => scope,
318
+ "scope" => scope == "cms" ? "embed" : scope,
258
319
  "hash" => Hashing.sha256(secret),
259
320
  "hint" => secret[-4..],
260
321
  "createdAt" => runlight.now,
@@ -268,6 +329,11 @@ module Runlight
268
329
  end
269
330
  # A hub's own address, from where it asked to be sent back, so the picker only ever sends choices there.
270
331
  runlight.store.set_setting("token-origin:#{row["id"]}", Http::Url.new(grant["redirect"].to_s).origin) if scope == "manage"
332
+ # A CMS plugin gets the site's agents key too, so connecting fills in every key its settings ask for.
333
+ if scope == "cms"
334
+ return json({ "access_token" => secret, "token_type" => "Bearer", "scope" => scope, "site" => grant["site"],
335
+ "agents_key" => agents_key(runlight, grant["site"].to_s) })
336
+ end
271
337
  # site is not part of OAuth, but a hub needs to know which site it was given.
272
338
  answer = { "access_token" => secret, "token_type" => "Bearer", "scope" => scope }
273
339
  answer["site"] = grant["site"] if grant["site"] != ""
@@ -287,12 +353,8 @@ module Runlight
287
353
 
288
354
  # The key client ids are signed with, made on first use and kept in the database for every process.
289
355
  def client_key(runlight)
290
- saved = runlight.store.setting("oauth-key")
291
- return saved if !saved.nil? && saved != ""
292
-
293
- made = Hashing.random_id(32)
294
- runlight.store.set_setting("oauth-key", made)
295
- made
356
+ # Written only if no other request wrote one first, so every process signs with the same key.
357
+ runlight.store.setting_or_add("oauth-key") { Hashing.random_id(32) }
296
358
  end
297
359
 
298
360
  # The app a client id names, and where to note that it connected: {"client" => ..., "usedKey" => ...}, or nil.
@@ -315,6 +377,11 @@ module Runlight
315
377
  { "client" => client, "usedKey" => used_key }
316
378
  end
317
379
 
380
+ # A redirect address's host as a site's domains are kept: lowercase, with no port and no www.
381
+ def site_host(redirect)
382
+ Http::Url.new(redirect).hostname.downcase.sub(/\.+\z/, "").sub(/\Awww\./, "")
383
+ end
384
+
318
385
  def constant_time_equal(a, b)
319
386
  a.bytesize == b.bytesize && OpenSSL.fixed_length_secure_compare(a, b)
320
387
  end
@@ -406,7 +473,7 @@ module Runlight
406
473
  )
407
474
  end
408
475
 
409
- private_class_method :base64url, :from_base64url, :client_key, :client_for, :constant_time_equal, :esc, :json, :oauth_error,
476
+ private_class_method :base64url, :from_base64url, :client_key, :client_for, :site_host, :constant_time_equal, :esc, :json, :oauth_error,
410
477
  :allowed_redirect, :json_form, :allow_registration, :register, :page
411
478
  end
412
479
  end
@@ -5,9 +5,10 @@ module Runlight
5
5
  # in the order the standalone server answers: a link domain added in Settings
6
6
  # first (it leaves the dashboard's own paths alone), then `{linkPath}/{slug}`
7
7
  # on the app's own domain, then the dashboard and API under the routes' base
8
- # path. As middleware, every other request goes on to the app, after AI agent
9
- # fetches among them are recorded (observe), which returns at once for
10
- # everyone else.
8
+ # path. As middleware, every other request goes on to the app, a short link
9
+ # with no link by that name included, so the app's own 404 page answers it,
10
+ # after AI agent fetches among them are recorded (observe), which returns at
11
+ # once for everyone else. Alone, Runlight answers those with its own 404.
11
12
  #
12
13
  # # config.ru, Runlight alone
13
14
  # run Runlight::RackApp.new(runlight: RL, base_path: "/runlight")
@@ -33,6 +34,8 @@ module Runlight
33
34
  @observe = observe
34
35
  @route_options = route_options
35
36
  @lock = Mutex.new
37
+ @warming = nil
38
+ @warm_failed_at = nil
36
39
  end
37
40
 
38
41
  def core
@@ -51,10 +54,16 @@ module Runlight
51
54
  rescue Http::Request::TooLarge
52
55
  # The rest is never read, so the connection is not fit for another request.
53
56
  return [413, { "content-type" => "application/json; charset=utf-8", "connection" => "close" }, [TOO_LARGE]]
57
+ rescue StandardError => e
58
+ raise if @app.nil?
59
+
60
+ # Whatever Runlight cannot read, the app behind still answers.
61
+ warn "Runlight: #{e.message}"
62
+ return @app.call(env)
54
63
  end
55
64
  response = answer(request)
56
65
  if response.nil?
57
- core.observe(request) if @observe && request.method == "GET"
66
+ observe(request) if @observe && request.method == "GET"
58
67
  return @app.call(env)
59
68
  end
60
69
  status, headers, body = response.to_rack
@@ -63,21 +72,35 @@ module Runlight
63
72
  end
64
73
 
65
74
  # The answer to one request, or nil when it is not Runlight's and an app sits behind.
75
+ #
76
+ # The app's own requests never wait on Runlight: until Runlight has made its tables they go straight to
77
+ # the app while init runs beside them (a link domain is known only once it has), and anything that goes
78
+ # wrong in Runlight is logged and the request still reaches the app.
66
79
  def answer(request)
67
80
  context = { "ip" => request.remote_address }
68
- linked = core.link_domain_response(request, context)
69
- return linked unless linked.nil?
70
-
71
81
  path = Http::Url.parse(request.url)&.pathname || "/"
72
82
  link_path = core.link_path
73
- if request.method == "GET" && path.match?(%r{\A#{Regexp.escape(link_path)}/[^/]+/?\z})
74
- return core.link_handler.call(request, context)
83
+ link = request.method == "GET" && path.match?(%r{\A#{Regexp.escape(link_path)}/[^/]+/?\z})
84
+ # The discovery documents OAuth clients read at the site's root are answered too, as under the base path.
85
+ own = @app.nil? || mine?(path) || link || Routes.oauth_document?(path)
86
+ if own || ready?
87
+ linked = core.link_domain_response(request, context)
88
+ return linked unless linked.nil?
89
+ end
90
+ if link
91
+ linked = core.link_handler.call(request, context)
92
+ # A slug with no link is the app's to answer, so its own 404 page shows.
93
+ return linked unless @app && linked.status == 404
94
+
95
+ own = mine?(path) || Routes.oauth_document?(path)
75
96
  end
76
- return routes.handle(request) if @app.nil? || mine?(path)
97
+ return routes.handle(request) if own
77
98
 
78
99
  nil
79
100
  rescue StandardError => e
80
101
  warn "Runlight: #{e.message}"
102
+ return nil if @app && !own
103
+
81
104
  Http::Response.new(INTERNAL, status: 500, headers: {
82
105
  "content-type" => "application/json; charset=utf-8",
83
106
  "cache-control" => "no-store",
@@ -87,6 +110,37 @@ module Runlight
87
110
 
88
111
  private
89
112
 
113
+ # Whether Runlight has made its tables. Until it has, an app's request starts init beside it, once at a
114
+ # time, and again no sooner than a minute after one that failed.
115
+ def ready?
116
+ return true unless core.respond_to?(:ready?)
117
+ return true if core.ready?
118
+
119
+ @lock.synchronize do
120
+ return false if @warming&.alive?
121
+ return false if @warm_failed_at && monotonic - @warm_failed_at < 60
122
+
123
+ @warming = Thread.new do
124
+ core.init
125
+ rescue StandardError => e
126
+ @warm_failed_at = monotonic
127
+ warn "Runlight: could not get ready #{e.message}"
128
+ end
129
+ end
130
+ false
131
+ end
132
+
133
+ # AI agent fetches are recorded once Runlight is ready, and never hold up or break the page.
134
+ def observe(request)
135
+ core.observe(request) if ready?
136
+ rescue StandardError => e
137
+ warn "Runlight: #{e.message}"
138
+ end
139
+
140
+ def monotonic
141
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
142
+ end
143
+
90
144
  def idle
91
145
  core.idle if core.respond_to?(:idle)
92
146
  rescue StandardError => e