runlight 0.2.0 → 0.3.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/README.md +1 -1
- data/assets/build.json +3 -3
- data/assets/dashboard.css +1 -1
- data/assets/dashboard.js +10 -10
- data/assets/locales.json +5 -5
- data/lib/runlight/accounts/accounts.rb +15 -5
- data/lib/runlight/accounts/crypto.rb +2 -0
- data/lib/runlight/accounts/throttle.rb +10 -19
- data/lib/runlight/accounts/web.rb +25 -3
- data/lib/runlight/core.rb +69 -31
- data/lib/runlight/dates.rb +38 -0
- data/lib/runlight/http/fetcher.rb +1 -1
- data/lib/runlight/importers/client.rb +6 -6
- data/lib/runlight/importers/index.rb +3 -2
- data/lib/runlight/importers/visits.rb +2 -2
- data/lib/runlight/oauth.rb +3 -1
- data/lib/runlight/payload.rb +18 -5
- data/lib/runlight/reports.rb +1 -1
- data/lib/runlight/routes.rb +33 -8
- data/lib/runlight/safefetch.rb +20 -14
- data/lib/runlight/server/config.rb +3 -2
- data/lib/runlight/store/sql.rb +2 -0
- data/lib/runlight/store/sql_store.rb +16 -1
- data/lib/runlight/version.rb +1 -1
- metadata +3 -7
data/lib/runlight/routes.rb
CHANGED
|
@@ -26,11 +26,17 @@ 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
|
|
32
36
|
# standalone server).
|
|
33
37
|
# - tokenMade: a callable taking a token row and who made it, returning whether that is allowed (internal).
|
|
38
|
+
# - tokensOf: a callable taking an account id, returning the ids of the tokens it made, so a member sees and
|
|
39
|
+
# revokes only their own (internal).
|
|
34
40
|
class Routes
|
|
35
41
|
COOKIE = "runlight_token"
|
|
36
42
|
IMPLEMENTATION = { "library" => "runlight", "language" => "ruby" }.freeze
|
|
@@ -169,6 +175,7 @@ module Runlight
|
|
|
169
175
|
@sign_out = options["signOut"].nil? ? (web ? "#{@base}/logout" : nil) : options["signOut"].to_s
|
|
170
176
|
@account_of = options["accountOf"] || (web ? ->(r) { web.account_of(r) } : nil)
|
|
171
177
|
@token_made = options["tokenMade"] || (web ? ->(row, by) { web.token_made(row, by) } : nil)
|
|
178
|
+
@tokens_of = options["tokensOf"] || (web ? ->(by) { web.tokens_of(by) } : nil)
|
|
172
179
|
authorize = options["authorize"]
|
|
173
180
|
oauth = {
|
|
174
181
|
"runlight" => runlight,
|
|
@@ -179,6 +186,7 @@ module Runlight
|
|
|
179
186
|
oauth["signIn"] = @sign_in if !@sign_in.nil? && @sign_in != ""
|
|
180
187
|
oauth["accountOf"] = @account_of unless @account_of.nil?
|
|
181
188
|
oauth["tokenMade"] = @token_made unless @token_made.nil?
|
|
189
|
+
oauth["csrf"] = options["csrf"] unless options["csrf"].nil?
|
|
182
190
|
@oauth = oauth
|
|
183
191
|
end
|
|
184
192
|
|
|
@@ -384,7 +392,7 @@ module Runlight
|
|
|
384
392
|
# The dashboard's page, which holds no data: the API it calls checks access. `embed` is the dashboard inside a
|
|
385
393
|
# CMS's admin pages: { "session", "origin" }, its session (empty once its ticket was used or ran out) and the
|
|
386
394
|
# admin origin that frames it.
|
|
387
|
-
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 = "")
|
|
388
396
|
b = escape_attr(base)
|
|
389
397
|
hash = build_hash("dashboardHash")
|
|
390
398
|
attributes = (share != "" ? " data-share=\"#{escape_attr(share)}\"" : "") +
|
|
@@ -393,6 +401,7 @@ module Runlight
|
|
|
393
401
|
(geo_credit ? ' data-geo-credit=""' : "") +
|
|
394
402
|
(accounts ? ' data-accounts=""' : "") +
|
|
395
403
|
(cms != "" ? " data-cms=\"#{escape_attr(cms)}\"" : "") +
|
|
404
|
+
(csrf != "" ? " data-csrf=\"#{escape_attr(csrf)}\"" : "") +
|
|
396
405
|
(embed.nil? ? "" : " data-embed=\"#{escape_attr(embed["session"])}\" data-embed-origin=\"#{escape_attr(embed["origin"])}\"")
|
|
397
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" \
|
|
398
407
|
"<link rel=\"icon\" href=\"#{Brand.runlight_icon}\">\n" \
|
|
@@ -471,12 +480,14 @@ module Runlight
|
|
|
471
480
|
n.is_a?(Float) ? (n.finite? ? n.to_i : 0) : n.to_i
|
|
472
481
|
end
|
|
473
482
|
|
|
474
|
-
# The controls a member cannot change: the mail service and its keys, the assistant's settings,
|
|
483
|
+
# The controls a member cannot change: the mail service and its keys, the assistant's settings, a site's agents key,
|
|
484
|
+
# and deleting a site.
|
|
475
485
|
def admin_only?(path, method)
|
|
476
486
|
(path == "/api/mail" && %w[PUT DELETE].include?(method)) ||
|
|
477
487
|
(path == "/api/assistant" && %w[PUT DELETE].include?(method)) ||
|
|
478
488
|
(path == "/api/assistant/limits" && method == "PUT") ||
|
|
479
489
|
(path == "/api/assistant/models" && method == "POST") ||
|
|
490
|
+
(path == "/api/observe-key/new" && method == "POST") ||
|
|
480
491
|
(path.match?(%r{\A/api/sites/[^/]+\z}) && method == "DELETE")
|
|
481
492
|
end
|
|
482
493
|
|
|
@@ -1253,7 +1264,7 @@ module Runlight
|
|
|
1253
1264
|
"to" => to,
|
|
1254
1265
|
"subject" => t.call("email.test.subject"),
|
|
1255
1266
|
"text" => t.call("email.test.body", { "service" => name }),
|
|
1256
|
-
"html" => "<
|
|
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>",
|
|
1257
1268
|
})
|
|
1258
1269
|
# Counted once it went out, so a test that failed can be tried again as soon as the setting is fixed.
|
|
1259
1270
|
@lock.synchronize { @sample_sent[tested] = @rl.now }
|
|
@@ -1481,13 +1492,23 @@ module Runlight
|
|
|
1481
1492
|
nil
|
|
1482
1493
|
end
|
|
1483
1494
|
|
|
1495
|
+
# The tokens the account behind a request made: none when nothing notes who made what.
|
|
1496
|
+
def own_tokens(request)
|
|
1497
|
+
by = @account_of&.call(request)
|
|
1498
|
+
!by.nil? && by != "" && @tokens_of ? @tokens_of.call(by) : Set.new
|
|
1499
|
+
end
|
|
1500
|
+
|
|
1484
1501
|
def tokens_api(request, path)
|
|
1485
1502
|
@rl.init
|
|
1486
1503
|
view = lambda do |t|
|
|
1487
1504
|
{ "id" => t["id"], "name" => t["name"], "site" => t["site"], "scope" => t["scope"], "hint" => t["hint"],
|
|
1488
1505
|
"createdAt" => t["createdAt"], "lastUsedAt" => t["lastUsedAt"] }
|
|
1489
1506
|
end
|
|
1490
|
-
|
|
1507
|
+
# A member makes tokens like anyone who manages sites, but sees and revokes only the ones they made.
|
|
1508
|
+
mine = @members.key?(request) ? own_tokens(request) : nil
|
|
1509
|
+
if path == "/api/tokens" && request.method == "GET"
|
|
1510
|
+
return json({ "tokens" => store.tokens.select { |t| mine.nil? || mine.include?(t["id"]) }.map(&view) })
|
|
1511
|
+
end
|
|
1491
1512
|
|
|
1492
1513
|
if path == "/api/tokens" && request.method == "POST"
|
|
1493
1514
|
body = read_json(request)
|
|
@@ -1517,6 +1538,8 @@ module Runlight
|
|
|
1517
1538
|
return json({ "token" => view.call(row), "secret" => secret }, 201)
|
|
1518
1539
|
end
|
|
1519
1540
|
if (match = path.match(%r{\A/api/tokens/([a-f0-9]{24})\z})) && request.method == "DELETE"
|
|
1541
|
+
return coded("Unknown token", "unknown_token", 404) if mine && !mine.include?(match[1])
|
|
1542
|
+
|
|
1520
1543
|
return store.delete_token(match[1]) ? json({ "ok" => true }) : coded("Unknown token", "unknown_token", 404)
|
|
1521
1544
|
end
|
|
1522
1545
|
|
|
@@ -1898,7 +1921,7 @@ module Runlight
|
|
|
1898
1921
|
end
|
|
1899
1922
|
return chat(request, url) if path == "/api/assistant/chat" && method == "POST"
|
|
1900
1923
|
|
|
1901
|
-
# Only the
|
|
1924
|
+
# Only people at the dashboard manage tokens, a member only their own: an API token cannot make or revoke one.
|
|
1902
1925
|
if path == "/api/tokens" || path.start_with?("/api/tokens/")
|
|
1903
1926
|
access = can_read(request)
|
|
1904
1927
|
return denied(access) if access != true
|
|
@@ -2456,8 +2479,10 @@ module Runlight
|
|
|
2456
2479
|
return json({ "site" => site["id"], "funnels" => store.funnels(site["id"]) }) if params.get("definitions") == "1"
|
|
2457
2480
|
# One funnel at a time, so a page of funnels never takes every database connection at once.
|
|
2458
2481
|
rows = store.funnels(site["id"]).map do |funnel|
|
|
2459
|
-
|
|
2460
|
-
|
|
2482
|
+
counted = store.funnel_counts(query, funnel)
|
|
2483
|
+
counts = counted["counts"]
|
|
2484
|
+
row = funnel.merge("steps" => funnel["steps"].each_with_index.map { |step, i| step.merge("visits" => counts[i]) })
|
|
2485
|
+
counted["sampled"] ? row.merge("sampled" => store.funnel_visits) : row
|
|
2461
2486
|
end
|
|
2462
2487
|
return json({ "site" => site["id"], "range" => range_out, "funnels" => rows })
|
|
2463
2488
|
end
|
|
@@ -2711,7 +2736,7 @@ module Runlight
|
|
|
2711
2736
|
# the page explains how to sign in when it is refused.
|
|
2712
2737
|
# Inside a CMS, its admin, on this same origin, shows the whole dashboard in a frame; nothing else may.
|
|
2713
2738
|
cms = @options["cms"].to_s
|
|
2714
|
-
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: {
|
|
2715
2740
|
"content-type" => "text/html; charset=utf-8",
|
|
2716
2741
|
"cache-control" => "no-store",
|
|
2717
2742
|
"content-security-policy" => cms != "" ? DASHBOARD_CSP.sub("frame-ancestors 'none'", "frame-ancestors 'self'") : DASHBOARD_CSP,
|
data/lib/runlight/safefetch.rb
CHANGED
|
@@ -80,22 +80,30 @@ module Runlight
|
|
|
80
80
|
return false if g.nil?
|
|
81
81
|
|
|
82
82
|
embedded = ->(hi, lo) { [hi >> 8, hi & 255, lo >> 8, lo & 255] }
|
|
83
|
-
|
|
84
|
-
#
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
end
|
|
88
|
-
return public_v4?(embedded.call(g[6], g[7])) if g[0] == 0x64 && g[1] == 0xff9b && zero.call(g[2, 4])
|
|
83
|
+
# IPv4 inside IPv6: mapped (::ffff:0:0/96), the old compatible form (::/96), translated (::ffff:0:0:0/96),
|
|
84
|
+
# and NAT64 (64:ff9b::/96).
|
|
85
|
+
inside = embedded_v4(g)
|
|
86
|
+
return g[5].zero? && g[4].zero? && g[6].zero? && g[7] <= 1 ? false : public_v4?(inside) unless inside.nil?
|
|
89
87
|
# 6to4 carries an IPv4 address in its second and third groups.
|
|
90
88
|
return public_v4?(embedded.call(g[1], g[2])) if g[0] == 0x2002
|
|
91
|
-
|
|
92
|
-
#
|
|
93
|
-
return false if g[0]
|
|
94
|
-
|
|
89
|
+
# Only global unicast (2000::/3) is public, which leaves out unique local, link-local, the old site-local,
|
|
90
|
+
# multicast, discard, and local-use NAT64 (64:ff9b:1::/48), which may carry any address.
|
|
91
|
+
return false if (g[0] & 0xe000) != 0x2000
|
|
92
|
+
# The protocol assignments (2001::/23, with Teredo and ORCHID) and the documentation prefixes.
|
|
93
|
+
return false if g[0] == 0x2001 && (g[1] < 0x200 || g[1] == 0xdb8)
|
|
94
|
+
return false if (g[0] & 0xfff0) == 0x3ff0
|
|
95
95
|
|
|
96
96
|
true
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
+
# The IPv4 address inside a mapped, compatible, translated, or NAT64 IPv6 address, or nil.
|
|
100
|
+
def embedded_v4(g)
|
|
101
|
+
inside = (g[0, 5].all?(&:zero?) && (g[5] == 0xffff || g[5].zero?)) ||
|
|
102
|
+
(g[0, 4].all?(&:zero?) && g[4] == 0xffff && g[5].zero?) ||
|
|
103
|
+
(g[0] == 0x64 && g[1] == 0xff9b && g[2, 4].all?(&:zero?))
|
|
104
|
+
inside ? [g[6] >> 8, g[6] & 255, g[7] >> 8, g[7] & 255] : nil
|
|
105
|
+
end
|
|
106
|
+
|
|
99
107
|
# Names clouds give their metadata services.
|
|
100
108
|
METADATA_NAMES = %w[metadata metadata.google.internal metadata.goog instance-data instance-data.ec2.internal].freeze
|
|
101
109
|
|
|
@@ -105,10 +113,8 @@ module Runlight
|
|
|
105
113
|
def metadata_address?(ip)
|
|
106
114
|
four = v4(ip)
|
|
107
115
|
g = four.nil? ? v6(ip) : nil
|
|
108
|
-
# IPv4 inside IPv6, mapped
|
|
109
|
-
|
|
110
|
-
four = [g[6] >> 8, g[6] & 255, g[7] >> 8, g[7] & 255]
|
|
111
|
-
end
|
|
116
|
+
# IPv4 inside IPv6, mapped, compatible, translated, or NAT64, is read as IPv4.
|
|
117
|
+
four = embedded_v4(g) if four.nil? && !g.nil?
|
|
112
118
|
return (four[0] == 169 && four[1] == 254) || four == [100, 100, 100, 200] unless four.nil?
|
|
113
119
|
return false if g.nil?
|
|
114
120
|
return true if (g[0] & 0xffc0) == 0xfe80
|
|
@@ -16,7 +16,8 @@ module Runlight
|
|
|
16
16
|
# RUNLIGHT_SECRET signs sessions and encrypts saved keys (made and kept in DATA_DIR if unset)
|
|
17
17
|
# RUNLIGHT_TOKEN also accepted as a bearer token on the API, and makes the first account
|
|
18
18
|
# RUNLIGHT_URL the dashboard's public address, which can never become a link domain
|
|
19
|
-
# TRUST_PROXY
|
|
19
|
+
# TRUST_PROXY unset believes forwarded addresses only from a private network; "false" never,
|
|
20
|
+
# "true" from anywhere, or the one header a CDN sets, such as cf-connecting-ip
|
|
20
21
|
# RUNLIGHT_GEO city (the default), country, off, or the path to an MMDB file
|
|
21
22
|
# CRON_SECRET lets a scheduler run the check over HTTP, at POST /api/check
|
|
22
23
|
# RUNLIGHT_OBSERVE_KEY one key for every site's AI agent reports
|
|
@@ -115,7 +116,7 @@ module Runlight
|
|
|
115
116
|
end
|
|
116
117
|
|
|
117
118
|
# false with nothing in front, or the one header your proxy sets, such as cf-connecting-ip behind Cloudflare.
|
|
118
|
-
# Unset stays nil, so the library's default applies
|
|
119
|
+
# Unset stays nil, so the library's default applies: forwarding headers count only from a private address.
|
|
119
120
|
def trust_proxy
|
|
120
121
|
value = (get("TRUST_PROXY") || "").downcase
|
|
121
122
|
return nil if value == ""
|
data/lib/runlight/store/sql.rb
CHANGED
|
@@ -27,6 +27,8 @@ module Runlight
|
|
|
27
27
|
|
|
28
28
|
# The most visits journeys reads, newest first.
|
|
29
29
|
JOURNEY_VISITS = 20_000
|
|
30
|
+
# The most visits a funnel is counted over: the newest in the range, so its rows fit in memory over any range.
|
|
31
|
+
FUNNEL_VISITS = 100_000
|
|
30
32
|
|
|
31
33
|
# How long after a visit starts its events are looked for: far past any real visit.
|
|
32
34
|
EVENT_TAIL_MS = 2 * 86_400_000
|
|
@@ -25,6 +25,7 @@ module Runlight
|
|
|
25
25
|
class SqlStore
|
|
26
26
|
BOUNCE_MS = Sql::BOUNCE_MS
|
|
27
27
|
JOURNEY_VISITS = Sql::JOURNEY_VISITS
|
|
28
|
+
FUNNEL_VISITS = Sql::FUNNEL_VISITS
|
|
28
29
|
EVENT_TAIL_MS = Sql::EVENT_TAIL_MS
|
|
29
30
|
MYSQL_COLLATION = Sql::MYSQL_COLLATION
|
|
30
31
|
|
|
@@ -34,6 +35,8 @@ module Runlight
|
|
|
34
35
|
attr_reader :db,
|
|
35
36
|
# What the store's table and index names start with: `rl_` unless it was given another.
|
|
36
37
|
:prefix
|
|
38
|
+
# The most visits a funnel is counted over; smaller in tests, to try the cap.
|
|
39
|
+
attr_accessor :funnel_visits
|
|
37
40
|
|
|
38
41
|
# `prefix` takes the place of `rl_` at the start of every table and index name, so two apps can each keep
|
|
39
42
|
# Runlight's tables in one database. A database that is already prefixed keeps its own.
|
|
@@ -45,6 +48,7 @@ module Runlight
|
|
|
45
48
|
end
|
|
46
49
|
@db = db
|
|
47
50
|
@prefix = prefix
|
|
51
|
+
@funnel_visits = FUNNEL_VISITS
|
|
48
52
|
@ready = false
|
|
49
53
|
@checked_all = false
|
|
50
54
|
end
|
|
@@ -575,7 +579,18 @@ module Runlight
|
|
|
575
579
|
# How many visits reached each step, in order, within the same visit. Step
|
|
576
580
|
# one is the first matching row in the range; each later step must come
|
|
577
581
|
# after the step before it. Filters choose which visits enter the funnel.
|
|
582
|
+
# {"counts" => Array<Integer>, "sampled" => bool}
|
|
578
583
|
def funnel_counts(query, funnel)
|
|
584
|
+
# Past funnel_visits visits in the range, only the newest are counted: the range starts where the oldest
|
|
585
|
+
# of them began.
|
|
586
|
+
scope = Sql.visit_scope(query["filters"], query["site"], query["from"], query["to"], @db.dialect)
|
|
587
|
+
first = @db.all(
|
|
588
|
+
"SELECT COUNT(*) AS n, MIN(started_at) AS t FROM (SELECT s.started_at AS started_at FROM rl_sessions s WHERE s.site = ? AND s.started_at >= ? AND s.started_at < ? AND #{Sql::IS_VISIT}#{scope["sql"]}
|
|
589
|
+
ORDER BY s.started_at DESC, s.id LIMIT #{@funnel_visits + 1}) x",
|
|
590
|
+
[query["site"], query["from"], query["to"], *scope["params"]],
|
|
591
|
+
)[0] || {}
|
|
592
|
+
sampled = Sql.num(first["n"]) > @funnel_visits
|
|
593
|
+
query = query.merge("from" => [query["from"], Sql.num(first["t"]) + 1].max) if sampled
|
|
579
594
|
# The rows of the picked visits that match any step, in order, read once and walked here: a join from
|
|
580
595
|
# each step to the next is planned badly by Postgres, which cannot guess how many visits go on.
|
|
581
596
|
v = Sql.visit_rows(query["filters"], query["site"], query["from"], query["to"], @db.dialect)
|
|
@@ -611,7 +626,7 @@ module Runlight
|
|
|
611
626
|
reached += 1 if reached < counts.length && Sql.num(row["m#{reached}"]) == 1
|
|
612
627
|
end
|
|
613
628
|
close.call
|
|
614
|
-
counts
|
|
629
|
+
{ "counts" => counts, "sampled" => sampled }
|
|
615
630
|
end
|
|
616
631
|
|
|
617
632
|
# Each visit's pageviews in order, at most `per_visit` of them, for journeys.
|
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.
|
|
7
|
+
GEM_VERSION = "0.3.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,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: runlight
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jon C. Phillips
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: activerecord
|
|
@@ -33,7 +32,6 @@ dependencies:
|
|
|
33
32
|
description: 'Mount the Rails engine or the Rack app, add a script tag, and read your
|
|
34
33
|
stats at /runlight. The Ruby port of the runlight npm package: same tables, same
|
|
35
34
|
answers, same dashboard.'
|
|
36
|
-
email:
|
|
37
35
|
executables:
|
|
38
36
|
- runlight
|
|
39
37
|
extensions: []
|
|
@@ -157,7 +155,6 @@ metadata:
|
|
|
157
155
|
bug_tracker_uri: https://github.com/runlightsh/runlight/issues
|
|
158
156
|
documentation_uri: https://runlight.sh/docs/ruby/
|
|
159
157
|
rubygems_mfa_required: 'true'
|
|
160
|
-
post_install_message:
|
|
161
158
|
rdoc_options: []
|
|
162
159
|
require_paths:
|
|
163
160
|
- lib
|
|
@@ -172,8 +169,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
172
169
|
- !ruby/object:Gem::Version
|
|
173
170
|
version: '0'
|
|
174
171
|
requirements: []
|
|
175
|
-
rubygems_version: 3.
|
|
176
|
-
signing_key:
|
|
172
|
+
rubygems_version: 3.6.9
|
|
177
173
|
specification_version: 4
|
|
178
174
|
summary: Privacy friendly web analytics that lives inside your Ruby app.
|
|
179
175
|
test_files: []
|