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.
- checksums.yaml +4 -4
- data/assets/build.json +3 -3
- data/assets/dashboard.js +17 -9
- data/assets/locales.json +5 -5
- data/lib/runlight/accounts/accounts.rb +82 -13
- data/lib/runlight/accounts/throttle.rb +123 -38
- data/lib/runlight/accounts/web.rb +50 -14
- data/lib/runlight/assistant.rb +11 -0
- data/lib/runlight/connect.rb +2 -3
- data/lib/runlight/core.rb +23 -0
- data/lib/runlight/db/connect.rb +5 -2
- data/lib/runlight/db/db.rb +79 -7
- data/lib/runlight/http/fetcher.rb +4 -1
- data/lib/runlight/http/request.rb +14 -3
- data/lib/runlight/importers/csv_visits.rb +1 -1
- data/lib/runlight/importers/index.rb +10 -2
- data/lib/runlight/importers/visits.rb +17 -6
- data/lib/runlight/importers/write.rb +21 -6
- data/lib/runlight/links.rb +14 -9
- data/lib/runlight/mail/smtp.rb +2 -0
- data/lib/runlight/mail/smtp_session.rb +22 -1
- data/lib/runlight/mcp.rb +8 -1
- data/lib/runlight/oauth.rb +2 -3
- data/lib/runlight/rack_app.rb +54 -7
- data/lib/runlight/routes.rb +101 -55
- data/lib/runlight/safefetch.rb +35 -0
- data/lib/runlight/version.rb +1 -1
- metadata +2 -2
data/lib/runlight/routes.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
782
|
-
domain
|
|
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
|
|
1212
|
-
#
|
|
1213
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 })
|
data/lib/runlight/safefetch.rb
CHANGED
|
@@ -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)
|
data/lib/runlight/version.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-10-11 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|