otto 2.7.0 → 2.8.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/.github/workflows/ruby-lint.yml +9 -8
- data/.rubocop.yml +2 -1
- data/.rubocop_todo.yml +1384 -0
- data/CHANGELOG.rst +76 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +5 -5
- data/docs/.gitignore +1 -0
- data/docs/enrichment.md +128 -0
- data/docs/geo-country.md +11 -2
- data/docs/migrating/v2.3.0.md +55 -22
- data/lib/otto/core/error_handler.rb +15 -2
- data/lib/otto/env_keys.rb +50 -12
- data/lib/otto/privacy/anonymizer_resolver.rb +138 -0
- data/lib/otto/privacy/asn_resolver.rb +149 -0
- data/lib/otto/privacy/config.rb +159 -11
- data/lib/otto/privacy/core.rb +64 -1
- data/lib/otto/privacy/redacted_fingerprint.rb +15 -2
- data/lib/otto/privacy.rb +9 -0
- data/lib/otto/request.rb +56 -8
- data/lib/otto/security/config.rb +44 -6
- data/lib/otto/security/csp/policy.rb +5 -3
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +34 -8
- data/lib/otto/version.rb +1 -1
- metadata +10 -3
data/lib/otto/privacy.rb
CHANGED
|
@@ -7,6 +7,8 @@ require_relative 'privacy/config'
|
|
|
7
7
|
require_relative 'privacy/ip_privacy'
|
|
8
8
|
require_relative 'privacy/user_agent_privacy'
|
|
9
9
|
require_relative 'privacy/geo_resolver'
|
|
10
|
+
require_relative 'privacy/asn_resolver'
|
|
11
|
+
require_relative 'privacy/anonymizer_resolver'
|
|
10
12
|
require_relative 'privacy/redacted_fingerprint'
|
|
11
13
|
|
|
12
14
|
# Otto::Privacy module provides IP address anonymization and privacy features
|
|
@@ -21,6 +23,13 @@ require_relative 'privacy/redacted_fingerprint'
|
|
|
21
23
|
# - Geo-location resolution (country-level only): a configurable trusted header,
|
|
22
24
|
# built-in CDN provider headers, an optional local MaxMind-format (.mmdb)
|
|
23
25
|
# database looked up on the masked IP, or a custom resolver
|
|
26
|
+
# - ASN resolution (opt-in, default off): the network operator an address
|
|
27
|
+
# belongs to, from a local .mmdb looked up on the masked IP. Database-only —
|
|
28
|
+
# no CDN publishes a client-ASN header worth trusting
|
|
29
|
+
# - Anonymizer classification (opt-in, default off): whether an address is a
|
|
30
|
+
# known Tor / VPN / proxy / hosting egress, from a local .mmdb. The only
|
|
31
|
+
# lookup that reads the unmasked address, because anonymizer data lists
|
|
32
|
+
# individual nodes at /32 — it returns a label, never the address
|
|
24
33
|
# - User agent anonymization (removes version numbers)
|
|
25
34
|
#
|
|
26
35
|
# Privacy is ENABLED BY DEFAULT. To disable:
|
data/lib/otto/request.rb
CHANGED
|
@@ -15,6 +15,8 @@ class Otto
|
|
|
15
15
|
# def show(req, res)
|
|
16
16
|
# req.masked_ip # Privacy-safe masked IP
|
|
17
17
|
# req.geo_country # ISO country code
|
|
18
|
+
# req.asn # Network operator, when enabled
|
|
19
|
+
# req.anonymizer # Tor/VPN/proxy label, when enabled
|
|
18
20
|
# req.check_locale! # Set locale for request
|
|
19
21
|
# end
|
|
20
22
|
#
|
|
@@ -63,6 +65,10 @@ class Otto
|
|
|
63
65
|
# If you need the geo country:
|
|
64
66
|
# req.geo_country # => 'US' or nil
|
|
65
67
|
#
|
|
68
|
+
# If you need the opt-in enrichment signals (nil unless enabled):
|
|
69
|
+
# req.asn # => 'AS15169', '**', or nil
|
|
70
|
+
# req.anonymizer # => 'tor', 'none', '**', or nil
|
|
71
|
+
#
|
|
66
72
|
# If you need the full privacy fingerprint:
|
|
67
73
|
# req.redacted_fingerprint # => RedactedFingerprint object or nil
|
|
68
74
|
|
|
@@ -92,6 +98,39 @@ class Otto
|
|
|
92
98
|
redacted_fingerprint&.country || env['otto.privacy.geo_country']
|
|
93
99
|
end
|
|
94
100
|
|
|
101
|
+
# Get the Autonomous System Number of the client's network
|
|
102
|
+
#
|
|
103
|
+
# Opt-in: nil unless the operator enabled ASN resolution. '**' means it is
|
|
104
|
+
# enabled but nothing resolved — distinguishable from "switched off", which
|
|
105
|
+
# is what makes the three-state contract worth keeping.
|
|
106
|
+
#
|
|
107
|
+
# Read straight from env rather than through the fingerprint. The
|
|
108
|
+
# `fingerprint&.x || env[...]` idiom used above treats any falsey value as
|
|
109
|
+
# "fall through", which is wrong for a value whose vocabulary is fixed and
|
|
110
|
+
# whose absence is itself meaningful.
|
|
111
|
+
#
|
|
112
|
+
# @return [String, nil] 'AS15169', '**', or nil when disabled
|
|
113
|
+
# @example
|
|
114
|
+
# req.asn # => 'AS15169'
|
|
115
|
+
def asn
|
|
116
|
+
env['otto.privacy.asn']
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Get the anonymizing-egress classification for the client address
|
|
120
|
+
#
|
|
121
|
+
# Opt-in: nil unless the operator enabled classification. 'none' means the
|
|
122
|
+
# database was consulted and did not list the address; '**' means no
|
|
123
|
+
# database answered. Do not collapse the two — 'none' is evidence that the
|
|
124
|
+
# address is not a known egress, '**' is the absence of any evidence.
|
|
125
|
+
#
|
|
126
|
+
# @return [String, nil] 'tor', 'proxy', 'vpn', 'residential_proxy',
|
|
127
|
+
# 'hosting', 'anonymous', 'none', '**', or nil when disabled
|
|
128
|
+
# @example
|
|
129
|
+
# req.anonymizer # => 'tor'
|
|
130
|
+
def anonymizer
|
|
131
|
+
env['otto.privacy.anonymizer']
|
|
132
|
+
end
|
|
133
|
+
|
|
95
134
|
# Get anonymized user agent string
|
|
96
135
|
#
|
|
97
136
|
# Returns user agent with version numbers stripped for privacy.
|
|
@@ -241,19 +280,28 @@ class Otto
|
|
|
241
280
|
#
|
|
242
281
|
# Prefers the canonical decision recorded once by IPPrivacyMiddleware in
|
|
243
282
|
# env['otto.via_trusted_proxy'] — evaluated against the original peer before
|
|
244
|
-
# REMOTE_ADDR is masked, so it stays correct even after masking.
|
|
245
|
-
#
|
|
246
|
-
# (standalone request use)
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
#
|
|
283
|
+
# REMOTE_ADDR is masked, so it stays correct even after masking. The key is
|
|
284
|
+
# tri-state: written only when proxy trust is configured, so its absence
|
|
285
|
+
# covers both "middleware not mounted" (standalone request use) and "no
|
|
286
|
+
# proxy trust configured" — the fallback below answers both by evaluating
|
|
287
|
+
# the config directly against the current REMOTE_ADDR (an unconfigured
|
|
288
|
+
# config yields false, matching what the middleware would have implied).
|
|
289
|
+
#
|
|
290
|
+
# A peer earns trust two ways (#226): its identity matches a configured
|
|
291
|
+
# trusted-proxy CIDR (filter mode), or count-based depth mode is active —
|
|
292
|
+
# configuring a depth asserts the connecting peer is the operator's
|
|
293
|
+
# (non-enumerable) proxy tier. The fallback mirrors the grant
|
|
294
|
+
# IPPrivacyMiddleware records so the two paths cannot disagree.
|
|
251
295
|
#
|
|
252
296
|
# @return [Boolean]
|
|
253
297
|
def forwarded_by_trusted_proxy?
|
|
254
298
|
return env['otto.via_trusted_proxy'] if env.key?('otto.via_trusted_proxy')
|
|
255
299
|
|
|
256
|
-
|
|
300
|
+
config = otto_security_config
|
|
301
|
+
return false unless config
|
|
302
|
+
return true if config.trusted_proxy_depth_mode?
|
|
303
|
+
|
|
304
|
+
trusted_proxy?(env['REMOTE_ADDR'])
|
|
257
305
|
end
|
|
258
306
|
|
|
259
307
|
# See: http://stackoverflow.com/questions/10013812/how-to-prevent-jquery-ajax-from-following-a-redirect-after-a-post
|
data/lib/otto/security/config.rb
CHANGED
|
@@ -38,6 +38,23 @@ class Otto
|
|
|
38
38
|
hop count, not both.
|
|
39
39
|
MSG
|
|
40
40
|
|
|
41
|
+
# Error raised when an app-configured trusted geo header (ip_privacy
|
|
42
|
+
# geo_header) is combined with count-based depth mode. Geo headers are
|
|
43
|
+
# honored only for peers matching enumerated trusted_proxies CIDRs
|
|
44
|
+
# (geo_headers_trusted? gates on trusted_proxies_configured?) — a hop
|
|
45
|
+
# trusted by count cannot be verified as the geo-setting CDN — so a
|
|
46
|
+
# geo_header configured alongside a depth could never be consulted.
|
|
47
|
+
# Failing loud at config time replaces a silent database/'**' fallback
|
|
48
|
+
# at request time.
|
|
49
|
+
GEO_HEADER_DEPTH_CONFLICT_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
|
|
50
|
+
Cannot configure a trusted geo header (ip_privacy geo_header) together
|
|
51
|
+
with trusted_proxy_depth (count mode): geo headers are only honored
|
|
52
|
+
for peers matching enumerated trusted_proxies CIDRs, so the header
|
|
53
|
+
would be silently ignored. Use filter mode (add_trusted_proxy) for
|
|
54
|
+
header-based geo, or drop geo_header and use database-backed geo
|
|
55
|
+
(geo_db_path or geo_db_reader).
|
|
56
|
+
MSG
|
|
57
|
+
|
|
41
58
|
# Forwarded-header sources depth mode (#trusted_proxy_depth) can count
|
|
42
59
|
# hops from: X-Forwarded-For (default), the RFC 7239 Forwarded header, or
|
|
43
60
|
# Both (Forwarded when present, else X-Forwarded-For). Mirrors
|
|
@@ -219,18 +236,30 @@ class Otto
|
|
|
219
236
|
|
|
220
237
|
# Whether any trusted-proxy IP/CIDR/Regexp matchers are configured.
|
|
221
238
|
#
|
|
222
|
-
# This mirrors {#trusted_proxy?}, which consults the same matcher list
|
|
223
|
-
#
|
|
224
|
-
#
|
|
225
|
-
#
|
|
226
|
-
#
|
|
227
|
-
# applies to X-Forwarded-Proto). Used to gate geo-header trust.
|
|
239
|
+
# This mirrors {#trusted_proxy?}, which consults the same matcher list.
|
|
240
|
+
# It deliberately EXCLUDES count-based depth mode: depth grants the peer
|
|
241
|
+
# blanket trust for otto.via_trusted_proxy (#226), but it cannot verify
|
|
242
|
+
# that the hop is a geo-setting CDN, so header-based geo stays gated on
|
|
243
|
+
# enumerated matchers only. Used to gate geo-header trust.
|
|
228
244
|
#
|
|
229
245
|
# @return [Boolean] true when at least one trusted-proxy matcher exists
|
|
230
246
|
def trusted_proxies_configured?
|
|
231
247
|
@trusted_proxy_matchers.any?
|
|
232
248
|
end
|
|
233
249
|
|
|
250
|
+
# Whether ANY proxy-trust mode is configured — CIDR matchers (filter
|
|
251
|
+
# mode) or count-based depth. This is the gate for writing
|
|
252
|
+
# env['otto.via_trusted_proxy'] at all: when neither mode is configured
|
|
253
|
+
# the key is left ABSENT (tri-state contract), so downstream consumers
|
|
254
|
+
# can distinguish "operator configured trust and this peer failed it"
|
|
255
|
+
# (false) from "no proxy trust configured" (absent) and apply their own
|
|
256
|
+
# legacy heuristics only in the latter case.
|
|
257
|
+
#
|
|
258
|
+
# @return [Boolean] true when filter or depth mode is configured
|
|
259
|
+
def proxy_trust_configured?
|
|
260
|
+
trusted_proxies_configured? || trusted_proxy_depth_mode?
|
|
261
|
+
end
|
|
262
|
+
|
|
234
263
|
# Whether count-based ("trust the last N hops") proxy resolution is active.
|
|
235
264
|
#
|
|
236
265
|
# When true, Otto::Utils.resolve_client_ip ignores trusted-proxy CIDRs and
|
|
@@ -260,6 +289,9 @@ class Otto
|
|
|
260
289
|
|
|
261
290
|
validate_trusted_proxy_depth!(depth)
|
|
262
291
|
raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if depth.to_i >= 1 && @trusted_proxies.any?
|
|
292
|
+
# Depth-then-geo assignment order is caught by configure_ip_privacy;
|
|
293
|
+
# this catches geo-then-depth so both orders fail eagerly.
|
|
294
|
+
raise ArgumentError, GEO_HEADER_DEPTH_CONFLICT_MESSAGE if depth.to_i >= 1 && @ip_privacy_config&.geo_header
|
|
263
295
|
|
|
264
296
|
@trusted_proxy_depth = depth
|
|
265
297
|
end
|
|
@@ -763,6 +795,12 @@ class Otto
|
|
|
763
795
|
return if @trusted_proxy_depth.nil?
|
|
764
796
|
|
|
765
797
|
raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if @trusted_proxy_depth >= 1 && @trusted_proxies.any?
|
|
798
|
+
|
|
799
|
+
# Backstop for the direct path (ip_privacy_config.geo_header=) that
|
|
800
|
+
# bypasses both eager checks; the setters cover the common orders.
|
|
801
|
+
return unless @trusted_proxy_depth >= 1 && @ip_privacy_config&.geo_header
|
|
802
|
+
|
|
803
|
+
raise ArgumentError, GEO_HEADER_DEPTH_CONFLICT_MESSAGE
|
|
766
804
|
end
|
|
767
805
|
|
|
768
806
|
# Parse a value into an IPAddr, returning nil for invalid / non-IP input.
|
|
@@ -222,15 +222,17 @@ class Otto
|
|
|
222
222
|
|
|
223
223
|
# CSP directives for the development environment.
|
|
224
224
|
#
|
|
225
|
-
# Development mode allows inline scripts
|
|
226
|
-
#
|
|
225
|
+
# Development mode allows nonce-authorized inline scripts, inline styles,
|
|
226
|
+
# HTTP(S) scripts, and hot-reloading connections for build tools such as
|
|
227
|
+
# Vite. HTTP(S) script sources support both same-origin reverse proxies
|
|
228
|
+
# (for example Caddy) and direct local Vite servers on another port.
|
|
227
229
|
#
|
|
228
230
|
# @param nonce [String] nonce value injected into `script-src`
|
|
229
231
|
# @return [Array<String>] directive strings, each terminated with `;`
|
|
230
232
|
def development_directives(nonce)
|
|
231
233
|
[
|
|
232
234
|
"default-src 'none';",
|
|
233
|
-
"script-src 'nonce-#{nonce}'
|
|
235
|
+
"script-src 'self' 'nonce-#{nonce}' http: https:;",
|
|
234
236
|
"style-src 'self' 'unsafe-inline';",
|
|
235
237
|
"connect-src 'self' ws: wss: http: https:;", # Allow HTTP and all WebSocket connections for dev tools
|
|
236
238
|
"img-src 'self' data:;",
|
|
@@ -21,7 +21,9 @@ class Otto
|
|
|
21
21
|
# Because it now runs ahead of everything, facts about the ORIGINAL peer
|
|
22
22
|
# that downstream code can no longer derive from the (masked) REMOTE_ADDR
|
|
23
23
|
# are recorded first, as leak-free booleans — never as addresses:
|
|
24
|
-
# env['otto.via_trusted_proxy']
|
|
24
|
+
# env['otto.via_trusted_proxy'] (only when proxy trust is configured —
|
|
25
|
+
# absent otherwise, see the tri-state note in #call) and
|
|
26
|
+
# env['otto.peer_loopback'].
|
|
25
27
|
#
|
|
26
28
|
# @example Default behavior (privacy enabled)
|
|
27
29
|
# # env['REMOTE_ADDR'] is masked to 192.168.1.0
|
|
@@ -62,11 +64,22 @@ class Otto
|
|
|
62
64
|
# secure? can authorize X-Forwarded-Proto canonically even after
|
|
63
65
|
# REMOTE_ADDR is rewritten to the masked client IP. Leak-free boolean.
|
|
64
66
|
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
|
|
67
|
+
# TRI-STATE: the key is written ONLY when the operator configured
|
|
68
|
+
# proxy trust (CIDR matchers or a depth). Present, its value is
|
|
69
|
+
# authoritative in both directions — true means the peer matched a
|
|
70
|
+
# CIDR (filter mode) or depth mode is active (configuring a depth
|
|
71
|
+
# asserts the connecting peer IS the operator's proxy tier, #226);
|
|
72
|
+
# false means trust IS configured and this peer failed it. Absent
|
|
73
|
+
# means no proxy trust is configured at all, so downstream consumers
|
|
74
|
+
# may fall back to their own heuristics without this key vetoing
|
|
75
|
+
# them. Writing false on unconfigured deployments made false
|
|
76
|
+
# ambiguous between "untrusted peer" and "nothing configured", which
|
|
77
|
+
# forced consumers into grant-only reads (#228).
|
|
78
|
+
# respond_to?: like geo_headers_trusted?, a partial/duck-typed
|
|
79
|
+
# config (or nil) that cannot report trust state is "unconfigured".
|
|
80
|
+
if @security_config.respond_to?(:proxy_trust_configured?) && @security_config.proxy_trust_configured?
|
|
81
|
+
env['otto.via_trusted_proxy'] = trusted_proxy?(env['REMOTE_ADDR'])
|
|
82
|
+
end
|
|
70
83
|
|
|
71
84
|
# Same rationale, for loopback: this middleware runs outermost, so a
|
|
72
85
|
# downstream middleware that must authenticate a DIRECT LOCAL CALL
|
|
@@ -207,7 +220,8 @@ class Otto
|
|
|
207
220
|
#
|
|
208
221
|
# This early return also means NONE of the privacy fingerprint
|
|
209
222
|
# values are produced for exempt IPs — no otto.privacy.fingerprint,
|
|
210
|
-
# masked_ip, hashed_ip, geo_country,
|
|
223
|
+
# masked_ip, hashed_ip, geo_country, asn, anonymizer, or
|
|
224
|
+
# correlation_hash. That is
|
|
211
225
|
# intentional and consistent: the correlation hash targets public
|
|
212
226
|
# audit-trail traffic, so req.ip_correlation_hash is nil for
|
|
213
227
|
# localhost / RFC-1918 addresses (the default dev path) even when a
|
|
@@ -238,6 +252,10 @@ class Otto
|
|
|
238
252
|
env['otto.privacy.masked_ip'] = fingerprint.masked_ip
|
|
239
253
|
env['otto.privacy.hashed_ip'] = fingerprint.hashed_ip
|
|
240
254
|
env['otto.privacy.geo_country'] = fingerprint.country
|
|
255
|
+
# nil unless the operator opted in; '**' when enabled but
|
|
256
|
+
# unresolved, so a consumer can tell "off" from "no answer".
|
|
257
|
+
env['otto.privacy.asn'] = fingerprint.asn
|
|
258
|
+
env['otto.privacy.anonymizer'] = fingerprint.anonymizer
|
|
241
259
|
|
|
242
260
|
# Fingerprint the FULL client IP here — while client_ip is still the
|
|
243
261
|
# real address, before REMOTE_ADDR is masked below — so it identifies
|
|
@@ -420,12 +438,20 @@ class Otto
|
|
|
420
438
|
Otto.logger.debug "[IPPrivacyMiddleware] Masked forwarded headers" if Otto.debug
|
|
421
439
|
end
|
|
422
440
|
|
|
423
|
-
# Check if
|
|
441
|
+
# Check if the connecting peer counts as a trusted proxy
|
|
442
|
+
#
|
|
443
|
+
# CIDR filter mode checks the peer's identity against the configured
|
|
444
|
+
# matchers. Count-based depth mode has no enumerable matchers — the
|
|
445
|
+
# depth setting itself is the operator's assertion that the peer is
|
|
446
|
+
# their proxy tier — so an active depth grants peer trust outright
|
|
447
|
+
# (#226). Geo-header trust is unaffected: geo_headers_trusted? gates on
|
|
448
|
+
# trusted_proxies_configured?, which stays matcher-only.
|
|
424
449
|
#
|
|
425
450
|
# @param ip [String] IP address to check
|
|
426
451
|
# @return [Boolean] true if IP is from a trusted proxy
|
|
427
452
|
def trusted_proxy?(ip)
|
|
428
453
|
return false unless @security_config
|
|
454
|
+
return true if @security_config.trusted_proxy_depth_mode?
|
|
429
455
|
|
|
430
456
|
@security_config.trusted_proxy?(ip)
|
|
431
457
|
end
|
data/lib/otto/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: otto
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.8.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Delano Mandelbaum
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
+
date: 2026-08-16 00:00:00.000000000 Z
|
|
11
12
|
dependencies:
|
|
12
13
|
- !ruby/object:Gem::Dependency
|
|
13
14
|
name: concurrent-ruby
|
|
@@ -131,6 +132,7 @@ files:
|
|
|
131
132
|
- ".reek.yml"
|
|
132
133
|
- ".rspec"
|
|
133
134
|
- ".rubocop.yml"
|
|
135
|
+
- ".rubocop_todo.yml"
|
|
134
136
|
- ".yardopts"
|
|
135
137
|
- AGENTS.md
|
|
136
138
|
- CHANGELOG.rst
|
|
@@ -145,6 +147,7 @@ files:
|
|
|
145
147
|
- docs/.gitignore
|
|
146
148
|
- docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md
|
|
147
149
|
- docs/1108-STREAMING_SUPPORT_SUMMARY.md
|
|
150
|
+
- docs/enrichment.md
|
|
148
151
|
- docs/geo-country.md
|
|
149
152
|
- docs/ipaddr-encoding-quirk.md
|
|
150
153
|
- docs/migrating/v2.0.0-pre1.md
|
|
@@ -255,6 +258,8 @@ files:
|
|
|
255
258
|
- lib/otto/mcp/schema_validation.rb
|
|
256
259
|
- lib/otto/mcp/server.rb
|
|
257
260
|
- lib/otto/privacy.rb
|
|
261
|
+
- lib/otto/privacy/anonymizer_resolver.rb
|
|
262
|
+
- lib/otto/privacy/asn_resolver.rb
|
|
258
263
|
- lib/otto/privacy/config.rb
|
|
259
264
|
- lib/otto/privacy/core.rb
|
|
260
265
|
- lib/otto/privacy/geo_resolver.rb
|
|
@@ -329,6 +334,7 @@ licenses:
|
|
|
329
334
|
- MIT
|
|
330
335
|
metadata:
|
|
331
336
|
rubygems_mfa_required: 'true'
|
|
337
|
+
post_install_message:
|
|
332
338
|
rdoc_options: []
|
|
333
339
|
require_paths:
|
|
334
340
|
- lib
|
|
@@ -346,7 +352,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
346
352
|
- !ruby/object:Gem::Version
|
|
347
353
|
version: '0'
|
|
348
354
|
requirements: []
|
|
349
|
-
rubygems_version: 3.
|
|
355
|
+
rubygems_version: 3.5.22
|
|
356
|
+
signing_key:
|
|
350
357
|
specification_version: 4
|
|
351
358
|
summary: Define your rack-apps in plaintext.
|
|
352
359
|
test_files: []
|