runlight 0.3.0 → 0.3.2

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.
@@ -298,7 +298,7 @@ module Runlight
298
298
  "to" => invite["email"],
299
299
  "subject" => "#{invite["invitedBy"]} invited you to Runlight",
300
300
  "text" => "#{invite["invitedBy"]} invited you to the Runlight at #{host} as #{what}.\n\nChoose a password to join:\n#{link}\n\nThe link works for seven days.\n",
301
- "html" => "<div style=\"font:15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#111827;max-width:480px\"><img src=\"https://runlight.sh/icon-192.png\" width=\"48\" height=\"48\" alt=\"Runlight\" style=\"display:block;width:48px;height:48px;margin-bottom:24px;border:0;border-radius:10px\"><p>" \
301
+ "html" => "<div style=\"font:15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#111827;max-width:480px\"><div role=\"img\" aria-label=\"Runlight\" style=\"width:48px;height:48px;margin-bottom:24px;border-radius:10px;background:#111827;color:#fff;font:700 26px/48px -apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif;text-align:center\">R</div><p>" \
302
302
  "#{esc(invite["invitedBy"])} invited you to the Runlight at #{esc(host)} as #{esc(what)}.</p><p><a href=\"#{esc(link)}\" " \
303
303
  "style=\"display:inline-block;padding:10px 18px;border-radius:8px;background:#111827;color:#ffffff;text-decoration:none;font-weight:600\">" \
304
304
  "Choose a password and join</a></p><p style=\"color:#6b7280;font-size:13px\">The link works for seven days. If you were not expecting this, " \
@@ -336,7 +336,7 @@ module Runlight
336
336
  "subject" => "Sign in to Runlight",
337
337
  "text" => "Someone, most likely you, signed in to Runlight at #{host} with your password while your account was held up by too many failed tries.\n\n" \
338
338
  "Sign in with this link within fifteen minutes:\n#{link}\n\nIf this was not you, change your password, since someone knows it.\n",
339
- "html" => "<div style=\"font:15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#111827;max-width:480px\"><img src=\"https://runlight.sh/icon-192.png\" width=\"48\" height=\"48\" alt=\"Runlight\" style=\"display:block;width:48px;height:48px;margin-bottom:24px;border:0;border-radius:10px\">" \
339
+ "html" => "<div style=\"font:15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#111827;max-width:480px\"><div role=\"img\" aria-label=\"Runlight\" style=\"width:48px;height:48px;margin-bottom:24px;border-radius:10px;background:#111827;color:#fff;font:700 26px/48px -apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif;text-align:center\">R</div>" \
340
340
  "<p>Someone, most likely you, signed in to Runlight at #{esc(host)} with your password while your account was held up by too many failed tries.</p>" \
341
341
  "<p><a href=\"#{esc(link)}\" style=\"display:inline-block;padding:10px 18px;border-radius:8px;background:#111827;color:#ffffff;" \
342
342
  "text-decoration:none;font-weight:600\">Sign in</a></p><p style=\"color:#6b7280;font-size:13px\">The link works for fifteen minutes. " \
@@ -46,6 +46,44 @@ module Runlight
46
46
  "systemv/hst10" => "Etc/GMT+10",
47
47
  # Names the database dropped and ICU kept.
48
48
  "canada/east-saskatchewan" => "America/Regina", "us/pacific-new" => "America/Los_Angeles",
49
+ # Old names the time zone database keeps as links in its backward file. Debian, and Ubuntu from 26.04, ship
50
+ # them apart (tzdata-legacy), so a system may not have them; ICU reads them as the links. From tzdata 2026e.
51
+ "africa/asmera" => "Africa/Asmara", "america/argentina/comodrivadavia" => "America/Argentina/Catamarca",
52
+ "america/buenos_aires" => "America/Argentina/Buenos_Aires", "america/catamarca" => "America/Argentina/Catamarca",
53
+ "america/cordoba" => "America/Argentina/Cordoba", "america/fort_wayne" => "America/Indiana/Indianapolis",
54
+ "america/godthab" => "America/Nuuk", "america/indianapolis" => "America/Indiana/Indianapolis",
55
+ "america/jujuy" => "America/Argentina/Jujuy", "america/knox_in" => "America/Indiana/Knox",
56
+ "america/louisville" => "America/Kentucky/Louisville", "america/mendoza" => "America/Argentina/Mendoza",
57
+ "america/rosario" => "America/Argentina/Cordoba", "antarctica/south_pole" => "Antarctica/McMurdo",
58
+ "asia/ashkhabad" => "Asia/Ashgabat", "asia/calcutta" => "Asia/Kolkata", "asia/choibalsan" => "Asia/Ulaanbaatar",
59
+ "asia/chungking" => "Asia/Shanghai", "asia/dacca" => "Asia/Dhaka", "asia/katmandu" => "Asia/Kathmandu",
60
+ "asia/macao" => "Asia/Macau", "asia/rangoon" => "Asia/Yangon", "asia/saigon" => "Asia/Ho_Chi_Minh",
61
+ "asia/thimbu" => "Asia/Thimphu", "asia/ujung_pandang" => "Asia/Makassar", "asia/ulan_bator" => "Asia/Ulaanbaatar",
62
+ "atlantic/faeroe" => "Atlantic/Faroe", "australia/act" => "Australia/Sydney", "australia/lhi" => "Australia/Lord_Howe",
63
+ "australia/north" => "Australia/Darwin", "australia/nsw" => "Australia/Sydney",
64
+ "australia/queensland" => "Australia/Brisbane", "australia/south" => "Australia/Adelaide",
65
+ "australia/tasmania" => "Australia/Hobart", "australia/victoria" => "Australia/Melbourne",
66
+ "australia/west" => "Australia/Perth", "brazil/acre" => "America/Rio_Branco", "brazil/denoronha" => "America/Noronha",
67
+ "brazil/east" => "America/Sao_Paulo", "brazil/west" => "America/Manaus", "canada/atlantic" => "America/Halifax",
68
+ "canada/central" => "America/Winnipeg", "canada/eastern" => "America/Toronto", "canada/mountain" => "America/Edmonton",
69
+ "canada/newfoundland" => "America/St_Johns", "canada/pacific" => "America/Vancouver",
70
+ "canada/saskatchewan" => "America/Regina", "canada/yukon" => "America/Whitehorse",
71
+ "chile/continental" => "America/Santiago", "chile/easterisland" => "Pacific/Easter", "cst6cdt" => "America/Chicago",
72
+ "cuba" => "America/Havana", "egypt" => "Africa/Cairo", "eire" => "Europe/Dublin", "est5edt" => "America/New_York",
73
+ "europe/kiev" => "Europe/Kyiv", "europe/uzhgorod" => "Europe/Kyiv", "europe/zaporozhye" => "Europe/Kyiv",
74
+ "gb" => "Europe/London", "gb-eire" => "Europe/London", "gmt0" => "Etc/GMT", "greenwich" => "Etc/GMT",
75
+ "hongkong" => "Asia/Hong_Kong", "iceland" => "Atlantic/Reykjavik", "iran" => "Asia/Tehran", "israel" => "Asia/Jerusalem",
76
+ "jamaica" => "America/Jamaica", "japan" => "Asia/Tokyo", "kwajalein" => "Pacific/Kwajalein", "libya" => "Africa/Tripoli",
77
+ "mexico/bajanorte" => "America/Tijuana", "mexico/bajasur" => "America/Mazatlan", "mexico/general" => "America/Mexico_City",
78
+ "mst7mdt" => "America/Denver", "navajo" => "America/Denver", "nz" => "Pacific/Auckland", "nz-chat" => "Pacific/Chatham",
79
+ "pacific/enderbury" => "Pacific/Kanton", "pacific/ponape" => "Pacific/Pohnpei", "pacific/truk" => "Pacific/Chuuk",
80
+ "poland" => "Europe/Warsaw", "portugal" => "Europe/Lisbon", "prc" => "Asia/Shanghai", "pst8pdt" => "America/Los_Angeles",
81
+ "roc" => "Asia/Taipei", "rok" => "Asia/Seoul", "singapore" => "Asia/Singapore", "turkey" => "Europe/Istanbul",
82
+ "universal" => "Etc/UTC", "us/alaska" => "America/Anchorage", "us/aleutian" => "America/Adak",
83
+ "us/arizona" => "America/Phoenix", "us/central" => "America/Chicago", "us/east-indiana" => "America/Indiana/Indianapolis",
84
+ "us/eastern" => "America/New_York", "us/hawaii" => "Pacific/Honolulu", "us/indiana-starke" => "America/Indiana/Knox",
85
+ "us/michigan" => "America/Detroit", "us/mountain" => "America/Denver", "us/pacific" => "America/Los_Angeles",
86
+ "us/samoa" => "Pacific/Pago_Pago", "w-su" => "Europe/Moscow", "zulu" => "Etc/UTC",
49
87
  }.freeze
50
88
 
51
89
  OFFSET = /\A([+\-]|−)([01][0-9]|2[0-3])(?::?([0-5][0-9]))?\z/
@@ -23,7 +23,8 @@ module Runlight
23
23
  #
24
24
  # The context the routes pass is a Hash: "runlight", "base", "isOwner" (callable(request) giving a bool), and
25
25
  # optionally "signIn" (String), "isReader" (callable(request) giving a bool), "accountOf" (callable(request)
26
- # giving an account id or nil), and "tokenMade" (callable(token, by) giving a bool).
26
+ # giving an account id or nil), "tokenMade" (callable(token, by) giving a bool), and "csrf" (callable giving the
27
+ # token the consent form sends back in its csrf field).
27
28
  module OAuth
28
29
  CODE_MS = 5 * 60_000
29
30
  # An app stored before client ids were signed, which never finished connecting within a day, is removed.
@@ -206,6 +207,7 @@ module Runlight
206
207
  %w[response_type client_id redirect_uri code_challenge code_challenge_method state scope resource].each do |k|
207
208
  hidden << "<input type=\"hidden\" name=\"#{k}\" value=\"#{esc(form.get(k))}\">" unless form.get(k).nil?
208
209
  end
210
+ hidden << "<input type=\"hidden\" name=\"csrf\" value=\"#{esc(ctx["csrf"].call.to_s)}\">" if ctx["csrf"]
209
211
  # The app names itself, so the page also shows where the answer goes, which it cannot fake.
210
212
  sends_to = "<p class=\"note\">#{h.call("oauth.sendsTo", { "host" => "<strong>#{esc(Http::Url.new(redirect).host)}</strong>" })}</p>"
211
213
  buttons = "<div class=\"buttons\"><button type=\"submit\" name=\"decision\" value=\"deny\" class=\"ghost\">#{h.call("oauth.deny")}</button>" \
@@ -183,7 +183,7 @@ module Runlight
183
183
  u = Http::Url.parse(links["dashboard"])
184
184
  where = u.nil? ? links["dashboard"] : u.host + u.pathname.sub(%r{/\z}, "")
185
185
  at = t.call("email.at", { "where" => where })
186
- mark = "<img src=\"https://runlight.sh/icon-192.png\" width=\"48\" height=\"48\" alt=\"Runlight\" style=\"display:block;width:48px;height:48px;margin-bottom:24px;border:0;border-radius:10px\">"
186
+ mark = "<div role=\"img\" aria-label=\"Runlight\" style=\"width:48px;height:48px;margin-bottom:24px;border-radius:10px;background:#111827;color:#fff;font:700 26px/48px -apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif;text-align:center\">R</div>"
187
187
 
188
188
  footer = t.call("email.footer", { "frequency" => t.call(monthly ? "email.monthly" : "email.weekly"), "site" => site["name"] })
189
189
  at_line = esc(t.call("email.at", { "where" => "\0" }))
@@ -26,6 +26,10 @@ module Runlight
26
26
  # - cms: the CMS that adds the script to every page, such as "WordPress", when Runlight runs inside one. The
27
27
  # dashboard then says so in place of the steps to add the script, and pages of the same origin, the CMS's own
28
28
  # admin, may show the whole dashboard in a frame. The WordPress, Drupal, and Craft plugins set it.
29
+ # - csrf: a callable returning a token the dashboard sends back with every change it makes, in an x-runlight-csrf
30
+ # header, and the OAuth consent form in a csrf field, for an authorize that checks where a change comes from, as
31
+ # a CMS checks its own forms. Asked for each time the dashboard or the form is shown. The WordPress plugin gives
32
+ # a nonce.
29
33
  # - origin: the address people open the app at, such as https://example.com.
30
34
  # - ownHosts: a callable returning more names the dashboard is reached at.
31
35
  # - accountOf: a callable taking the Request and returning the account it comes from, or nil (internal, for the
@@ -182,6 +186,7 @@ module Runlight
182
186
  oauth["signIn"] = @sign_in if !@sign_in.nil? && @sign_in != ""
183
187
  oauth["accountOf"] = @account_of unless @account_of.nil?
184
188
  oauth["tokenMade"] = @token_made unless @token_made.nil?
189
+ oauth["csrf"] = options["csrf"] unless options["csrf"].nil?
185
190
  @oauth = oauth
186
191
  end
187
192
 
@@ -387,7 +392,7 @@ module Runlight
387
392
  # The dashboard's page, which holds no data: the API it calls checks access. `embed` is the dashboard inside a
388
393
  # CMS's admin pages: { "session", "origin" }, its session (empty once its ticket was used or ran out) and the
389
394
  # admin origin that frames it.
390
- def dashboard(base, share = "", sign_out = "", geo_credit = false, accounts = false, sign_in = "", embed = nil, cms = "")
395
+ def dashboard(base, share = "", sign_out = "", geo_credit = false, accounts = false, sign_in = "", embed = nil, cms = "", csrf = "")
391
396
  b = escape_attr(base)
392
397
  hash = build_hash("dashboardHash")
393
398
  attributes = (share != "" ? " data-share=\"#{escape_attr(share)}\"" : "") +
@@ -396,6 +401,7 @@ module Runlight
396
401
  (geo_credit ? ' data-geo-credit=""' : "") +
397
402
  (accounts ? ' data-accounts=""' : "") +
398
403
  (cms != "" ? " data-cms=\"#{escape_attr(cms)}\"" : "") +
404
+ (csrf != "" ? " data-csrf=\"#{escape_attr(csrf)}\"" : "") +
399
405
  (embed.nil? ? "" : " data-embed=\"#{escape_attr(embed["session"])}\" data-embed-origin=\"#{escape_attr(embed["origin"])}\"")
400
406
  "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex\">\n<title>Runlight</title>\n" \
401
407
  "<link rel=\"icon\" href=\"#{Brand.runlight_icon}\">\n" \
@@ -1258,7 +1264,7 @@ module Runlight
1258
1264
  "to" => to,
1259
1265
  "subject" => t.call("email.test.subject"),
1260
1266
  "text" => t.call("email.test.body", { "service" => name }),
1261
- "html" => "<img src=\"https://runlight.sh/icon-192.png\" width=\"48\" height=\"48\" alt=\"Runlight\" style=\"display:block;width:48px;height:48px;margin-bottom:24px;border:0;border-radius:10px\"><p style=\"font-family:sans-serif;font-size:15px\">#{escape_html(t.call("email.test.body", { "service" => name }))}</p>",
1267
+ "html" => "<div role=\"img\" aria-label=\"Runlight\" style=\"width:48px;height:48px;margin-bottom:24px;border-radius:10px;background:#111827;color:#fff;font:700 26px/48px -apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif;text-align:center\">R</div><p style=\"font-family:sans-serif;font-size:15px\">#{escape_html(t.call("email.test.body", { "service" => name }))}</p>",
1262
1268
  })
1263
1269
  # Counted once it went out, so a test that failed can be tried again as soon as the setting is fixed.
1264
1270
  @lock.synchronize { @sample_sent[tested] = @rl.now }
@@ -2730,7 +2736,7 @@ module Runlight
2730
2736
  # the page explains how to sign in when it is refused.
2731
2737
  # Inside a CMS, its admin, on this same origin, shows the whole dashboard in a frame; nothing else may.
2732
2738
  cms = @options["cms"].to_s
2733
- return Http::Response.new(dashboard(base, "", @sign_out || "", Js.truthy?(@options["geoCredit"]), !@web.nil?, @sign_in || "", nil, cms), status: 200, headers: {
2739
+ return Http::Response.new(dashboard(base, "", @sign_out || "", Js.truthy?(@options["geoCredit"]), !@web.nil?, @sign_in || "", nil, cms, @options["csrf"].nil? ? "" : @options["csrf"].call.to_s), status: 200, headers: {
2734
2740
  "content-type" => "text/html; charset=utf-8",
2735
2741
  "cache-control" => "no-store",
2736
2742
  "content-security-policy" => cms != "" ? DASHBOARD_CSP.sub("frame-ancestors 'none'", "frame-ancestors 'self'") : DASHBOARD_CSP,
@@ -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.3.0"
7
+ GEM_VERSION = "0.3.2"
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.3.0
4
+ version: 0.3.2
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-24 00:00:00.000000000 Z
11
+ date: 2026-10-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord