runlight 0.1.1 → 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.
@@ -22,6 +22,23 @@ module Runlight
22
22
  # Arbitrary but fixed, so every Runlight process takes the same lock to create tables.
23
23
  MIGRATION_LOCK = 7_331_906
24
24
 
25
+ # The longest a process waits for another one creating or upgrading the tables before it gives up.
26
+ LOCK_WAIT_MS = 30_000
27
+
28
+ @lock_wait = LOCK_WAIT_MS
29
+
30
+ class << self
31
+ # How long the lock for creating tables is waited for: LOCK_WAIT_MS, unless a test has set another.
32
+ def lock_wait_ms
33
+ @lock_wait
34
+ end
35
+
36
+ # For tests: the lock is waited for this long instead, or LOCK_WAIT_MS again with nil.
37
+ def lock_wait_in_tests(ms)
38
+ @lock_wait = ms || LOCK_WAIT_MS
39
+ end
40
+ end
41
+
25
42
  # One lock per MySQL database, so installs sharing a server do not wait on each other. Lock names are 64
26
43
  # characters at most.
27
44
  MYSQL_LOCK = "CONCAT('runlight:', LEFT(SHA2(COALESCE(DATABASE(), ''), 256), 48))"
@@ -34,6 +51,9 @@ module Runlight
34
51
  # they do not linger in the file's free space. Rails' pragmas setting takes this one only as on or off.
35
52
  SECURE_DELETE = "PRAGMA secure_delete = FAST"
36
53
 
54
+ # How long a SQLite connection waits for a lock another one holds, as Connect.sqlite sets it.
55
+ BUSY_TIMEOUT_MS = 5000
56
+
37
57
  attr_reader :connection_class
38
58
 
39
59
  # connection_class: an ActiveRecord::Base subclass whose pool to use (ActiveRecord::Base for the app's
@@ -106,7 +126,8 @@ module Runlight
106
126
  end
107
127
 
108
128
  # Runs the block while holding a database-wide lock, so two processes starting at once do not race to
109
- # create the same tables. Yields this database.
129
+ # create the same tables. Waits for the lock at most LOCK_WAIT_MS, then raises a TablesBusyError. Yields
130
+ # this database.
110
131
  def exclusive(&block)
111
132
  connection do |c|
112
133
  @held += 1
@@ -249,9 +270,20 @@ module Runlight
249
270
 
250
271
  c.execute(dialect == "sqlite" ? SECURE_DELETE : NO_BACKSLASH_ESCAPES_OFF, "Runlight")
251
272
  raw = c.instance_variable_get(:@raw_connection)
273
+ wait_without_the_lock(raw) if dialect == "sqlite"
252
274
  @set_up[raw] = true if raw
253
275
  end
254
276
 
277
+ # Rails 7.2 sets SQLite's busy timeout with busy_timeout, which sleeps in C holding Ruby's global lock. A
278
+ # thread waiting for the file's write lock then stops the thread of the same process that holds it from
279
+ # ever finishing, so the wait runs out and raises "database is locked". busy_handler_timeout= (sqlite3
280
+ # 2.x, and what Rails 8 uses) waits the same time but lets other threads run.
281
+ def wait_without_the_lock(raw)
282
+ return unless raw.respond_to?(:busy_handler_timeout=)
283
+
284
+ raw.busy_handler_timeout = BUSY_TIMEOUT_MS
285
+ end
286
+
255
287
  # A statement on a connection. A connection the server dropped (a restart, a failover, an idle timeout)
256
288
  # is replaced, as a pool replaces it, and the statement sent again: it never reached the server. Not
257
289
  # inside a transaction or a lock, whose work went with the connection.
@@ -298,7 +330,14 @@ module Runlight
298
330
  if dialect == "postgres"
299
331
  # Asked for again and again rather than waited on: a waiting statement would hold up an index being
300
332
  # built CONCURRENTLY by whoever has the lock, and the two would wait on each other for good.
301
- sleep 0.1 until truthy(value(c, "SELECT pg_try_advisory_lock(#{MIGRATION_LOCK}) AS ok"))
333
+ # Up to LOCK_WAIT_MS, so a request does not wait on a long index build for good.
334
+ wait = self.class.lock_wait_ms
335
+ until_at = monotonic_ms + wait
336
+ until truthy(value(c, "SELECT pg_try_advisory_lock(#{MIGRATION_LOCK}) AS ok"))
337
+ raise TablesBusyError, wait if monotonic_ms >= until_at
338
+
339
+ sleep 0.1
340
+ end
302
341
  begin
303
342
  return yield(self)
304
343
  ensure
@@ -309,11 +348,16 @@ module Runlight
309
348
  end
310
349
  end
311
350
  end
351
+ # Up to LOCK_WAIT_MS, so a request does not wait on a long index build for good.
352
+ wait = self.class.lock_wait_ms
353
+ until_at = monotonic_ms + wait
312
354
  loop do
313
- ok = value(c, "SELECT GET_LOCK(#{MYSQL_LOCK}, 5) AS ok")
355
+ left = ((until_at - monotonic_ms) / 1000.0).ceil.clamp(1, 5)
356
+ ok = value(c, "SELECT GET_LOCK(#{MYSQL_LOCK}, #{left}) AS ok")
314
357
  raise "Runlight: MySQL refused the lock for creating tables" if ok.nil?
315
358
  break if ok.to_i == 1
316
- # Not got within 5 seconds: another process is creating the tables. Ask again.
359
+ # Not got in time: another process is creating the tables. Ask again, until the wait is up.
360
+ raise TablesBusyError, wait if monotonic_ms >= until_at
317
361
  end
318
362
  begin
319
363
  # An index on a big table takes a while to build, so the build may run past the statement timeout.
@@ -330,6 +374,10 @@ module Runlight
330
374
  end
331
375
  end
332
376
 
377
+ def monotonic_ms
378
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
379
+ end
380
+
333
381
  # The first column of a statement's first row, always asked of the database. select_value goes through
334
382
  # Rails' query cache, which a Rails request turns on for every pool, so a poll for a lock would see its
335
383
  # first answer again and again; exec_query is never cached.
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runlight
4
+ module Db
5
+ # A database whose tables and indexes start with a prefix of the app's choosing in place of `rl_`, such as
6
+ # `wp_rl_` beside a WordPress database's tables, so two apps sharing one database each keep a set of their
7
+ # own. Runlight's SQL names every table and index `rl_` and something, and those names alone are rewritten
8
+ # on the way through, in each statement and in the names a schema check compares as text. A store given a
9
+ # prefix puts one in front of its database, so the accounts and importers that reach the database through
10
+ # the store follow it too. Values bound to `?` are never rewritten.
11
+ class Prefixed
12
+ # Whatever else the database underneath has (close, limit_statements, connection_class) is passed on.
13
+ OPTIONAL = %i[affected turn metered?].freeze
14
+ private_constant :OPTIONAL
15
+
16
+ attr_reader :inner, :prefix
17
+
18
+ def initialize(inner, prefix)
19
+ # A name in a statement, and short enough that the longest index name stays inside MySQL's 64.
20
+ unless prefix.is_a?(String) && prefix.match?(/\A[A-Za-z_][A-Za-z0-9_]{0,31}\z/)
21
+ raise ArgumentError, "Runlight: a table prefix is up to 32 letters, digits, and underscores, and does not start with a digit"
22
+ end
23
+
24
+ @inner = inner
25
+ @prefix = prefix
26
+ end
27
+
28
+ # A statement with each of Runlight's names under the prefix.
29
+ def sql(text)
30
+ text.gsub(/\brl_(?=[a-z])/, @prefix)
31
+ end
32
+
33
+ def dialect
34
+ @inner.dialect
35
+ end
36
+
37
+ def all(text, params = [])
38
+ @inner.all(sql(text), params)
39
+ end
40
+
41
+ def run(text, params = [])
42
+ @inner.run(sql(text), params)
43
+ end
44
+
45
+ def affected(text, params = [])
46
+ @inner.affected(sql(text), params)
47
+ end
48
+
49
+ def metered?
50
+ @inner.metered?
51
+ end
52
+
53
+ def transaction
54
+ @inner.transaction { |db| yield wrap(db) }
55
+ end
56
+
57
+ def exclusive
58
+ @inner.exclusive { |db| yield wrap(db) }
59
+ end
60
+
61
+ def turn
62
+ @inner.turn { |db| yield wrap(db) }
63
+ end
64
+
65
+ def close
66
+ @inner.close
67
+ end
68
+
69
+ def respond_to?(name, include_all = false)
70
+ return @inner.respond_to?(name, include_all) if OPTIONAL.include?(name.to_sym)
71
+
72
+ super
73
+ end
74
+
75
+ private
76
+
77
+ def wrap(db)
78
+ db.equal?(@inner) ? self : Prefixed.new(db, @prefix)
79
+ end
80
+
81
+ def method_missing(name, ...)
82
+ return super unless @inner.respond_to?(name)
83
+
84
+ @inner.public_send(name, ...)
85
+ end
86
+
87
+ def respond_to_missing?(name, include_all = false)
88
+ @inner.respond_to?(name, include_all) || super
89
+ end
90
+ end
91
+ end
92
+ end
data/lib/runlight/geo.rb CHANGED
@@ -102,9 +102,10 @@ module Runlight
102
102
  end
103
103
 
104
104
  # The location of a request: the platform's headers when they name a country, else the lookup's answer
105
- # for the address, else nowhere.
106
- def locate(headers, ip, lookup = nil)
107
- from_headers = location_from_headers(headers)
105
+ # for the address, else nowhere. The headers are believed only with trust_headers, as the address is: without
106
+ # a proxy in front, a client writes them itself.
107
+ def locate(headers, ip, lookup = nil, trust_headers = true)
108
+ from_headers = trust_headers ? location_from_headers(headers) : nil
108
109
  return from_headers if !from_headers.nil? && from_headers["country"] != ""
109
110
 
110
111
  if !lookup.nil? && ip != ""
@@ -58,6 +58,20 @@ module Runlight
58
58
 
59
59
  private
60
60
 
61
+ # Opens the connection. A name pinned to several addresses is tried address by address, as a connection
62
+ # by name is, so a host that listens on one of them is reached; only the failure of the last is raised.
63
+ def connect(http, addresses)
64
+ tries = addresses.empty? ? [nil] : addresses
65
+ tries.each_with_index do |address, at|
66
+ http.ipaddr = address unless address.nil?
67
+ begin
68
+ return http.start
69
+ rescue SystemCallError, SocketError, Net::OpenTimeout
70
+ raise if at == tries.length - 1
71
+ end
72
+ end
73
+ end
74
+
61
75
  def monotonic
62
76
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
63
77
  end
@@ -78,13 +92,18 @@ module Runlight
78
92
  http = pinned ? Net::HTTP.new(host, port, nil) : Net::HTTP.new(host, port)
79
93
  http.use_ssl = https
80
94
  # A pin connects to the checked address, with the name kept for the Host header and the certificate.
95
+ addresses = []
81
96
  Array(init["resolve"]).each do |pin|
82
- m = pin.to_s.match(/\A(.+):(\d+):\[?([^\],]+)\]?/)
83
- next unless m && m[1].casecmp?(host) && m[2].to_i == port
97
+ lead = "#{host}:#{port}:"
98
+ next unless pin.to_s.length > lead.length && pin.to_s[0, lead.length].casecmp?(lead)
84
99
 
85
- http.ipaddr = m[3]
100
+ addresses = pin.to_s[lead.length..].split(",").map { |address| address.delete_prefix("[").delete_suffix("]") }.reject(&:empty?)
101
+ http.ipaddr = addresses.first
86
102
  break
87
103
  end
104
+ # A pin that names another host or port would leave the name to be looked up again: refused, never
105
+ # fetched unpinned.
106
+ raise FetchError, "fetch failed" if pinned && http.ipaddr.nil?
88
107
  http.open_timeout = [left, 15].min
89
108
  http.read_timeout = left
90
109
  http.write_timeout = left
@@ -108,8 +127,9 @@ module Runlight
108
127
  # Past maxBytes the whole request is left by a throw, which closes the connection: a break would leave
109
128
  # only read_body, and Net::HTTP would then read the rest of the body after all.
110
129
  catch(:too_long) do
111
- http.start do |connection|
112
- connection.request(request) do |answer|
130
+ connect(http, addresses)
131
+ begin
132
+ http.request(request) do |answer|
113
133
  status = answer.code.to_i
114
134
  answer.each_capitalized_name { |name| answer_headers[name.downcase] = answer.get_fields(name) }
115
135
  answer.read_body do |chunk|
@@ -123,6 +143,8 @@ module Runlight
123
143
  received << chunk
124
144
  end
125
145
  end
146
+ ensure
147
+ http.finish if http.started?
126
148
  end
127
149
  end
128
150
  rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error
@@ -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
@@ -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}"}" })
@@ -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))
@@ -248,12 +306,16 @@ module Runlight
248
306
  runlight.store.set_setting(found["usedKey"], stored_client ? Json.encode(used) : runlight.now.to_s)
249
307
  end
250
308
  secret = "rl_#{Hashing.random_id(20)}"
251
- 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.
252
314
  row = {
253
315
  "id" => Hashing.random_id,
254
316
  "name" => Js.slice("#{Js.string(client["name"].nil? ? "An app" : client["name"])} (OAuth)", 0, 100),
255
317
  "site" => grant["site"].to_s,
256
- "scope" => scope,
318
+ "scope" => scope == "cms" ? "embed" : scope,
257
319
  "hash" => Hashing.sha256(secret),
258
320
  "hint" => secret[-4..],
259
321
  "createdAt" => runlight.now,
@@ -267,6 +329,11 @@ module Runlight
267
329
  end
268
330
  # A hub's own address, from where it asked to be sent back, so the picker only ever sends choices there.
269
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
270
337
  # site is not part of OAuth, but a hub needs to know which site it was given.
271
338
  answer = { "access_token" => secret, "token_type" => "Bearer", "scope" => scope }
272
339
  answer["site"] = grant["site"] if grant["site"] != ""
@@ -286,12 +353,8 @@ module Runlight
286
353
 
287
354
  # The key client ids are signed with, made on first use and kept in the database for every process.
288
355
  def client_key(runlight)
289
- saved = runlight.store.setting("oauth-key")
290
- return saved if !saved.nil? && saved != ""
291
-
292
- made = Hashing.random_id(32)
293
- runlight.store.set_setting("oauth-key", made)
294
- 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) }
295
358
  end
296
359
 
297
360
  # The app a client id names, and where to note that it connected: {"client" => ..., "usedKey" => ...}, or nil.
@@ -314,6 +377,11 @@ module Runlight
314
377
  { "client" => client, "usedKey" => used_key }
315
378
  end
316
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
+
317
385
  def constant_time_equal(a, b)
318
386
  a.bytesize == b.bytesize && OpenSSL.fixed_length_secure_compare(a, b)
319
387
  end
@@ -405,7 +473,7 @@ module Runlight
405
473
  )
406
474
  end
407
475
 
408
- 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,
409
477
  :allowed_redirect, :json_form, :allow_registration, :register, :page
410
478
  end
411
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")
@@ -86,7 +87,13 @@ module Runlight
86
87
  linked = core.link_domain_response(request, context)
87
88
  return linked unless linked.nil?
88
89
  end
89
- return core.link_handler.call(request, context) if link
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)
96
+ end
90
97
  return routes.handle(request) if own
91
98
 
92
99
  nil
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runlight
4
+ # Refused before anything was sent there, because a service's redirect led to another origin.
5
+ class RedirectRefusedError < RuntimeError
6
+ attr_reader :host, :to
7
+
8
+ def initialize(host, to)
9
+ @host = host
10
+ @to = to
11
+ super("#{host} redirected to #{to}. Runlight sends a service's key and request only to the address it was set up with, so set it up with that address instead")
12
+ end
13
+ end
14
+ end