camada 0.1.0

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.
@@ -0,0 +1,363 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require_relative "../constants"
5
+ require_relative "../ipparse"
6
+
7
+ module Camada
8
+ # BLK snapshot parser (v3, v4, v5), ported from @camada/core src/snapshot/parse.ts, itself a
9
+ # port of edge-analyst src/blocklist.js load() (the reference implementation).
10
+ #
11
+ # Container: sectioned little-endian uint32 —
12
+ # [0] magic 0x424c4b3<version> [1] section count K
13
+ # K x [type, offset(words), length(words)] then the sections.
14
+ # Types: 1 V4_STARTS 2 V4_ENDS 3 V4_IDX16 4 V4_BM24 5 V6_STARTS 6 V6_ENDS 7 V6_BM24
15
+ # 8 ASN_BM 9 ASN_EXTRA.
16
+ # v4 (contracts §A3) adds two side lists as INTERLEAVED range pairs:
17
+ # 10 ALLOW_V4 11 ALLOW_V6 12 CHALLENGE_V4 13 CHALLENGE_V6
18
+ # *_V4: [start, end, …] (2 words per range, sorted by start)
19
+ # *_V6: [s0,s1,s2,s3, e0,e1,e2,e3, …] (8 words per range, big-endian word order, sorted by start)
20
+ # v5 (contracts §D3) adds the tenant's ordered custom rules, which run BEFORE the three sides:
21
+ # 14 RULE_V4 15 RULE_V6 — repeated, word 0 = the rule's index into meta.rules, then range
22
+ # pairs exactly as 10/11. One 14 + one 15 per `ip` condition, in condition order (an empty half
23
+ # still ships its index word), so a rule with two ip conditions reads two pairs.
24
+ # Meta travels separately: { version, country[], tls[], pathsExact[], pathsPrefix[], pathsRegex[],
25
+ # allow?: side, challenge?: side, rules?: [] } with side = { asn[], country[], pathsExact[], pathsPrefix[] }.
26
+ # The version byte is advisory: sections 10-15 are read whenever they are present.
27
+ #
28
+ # A Uint32Array is a `Words` view: one String of bytes read a word at a time with
29
+ # String#unpack1("V", offset:), so a 5 MB container never becomes 1.3 M Integers, and the
30
+ # bitmaps are indexed, never unpacked wholesale. Ruby integers are unbounded, so every
31
+ # `>>> 0` of the original is simply absent.
32
+ module Snapshot
33
+ FORMATS = { 0x424C4B33 => 3, 0x424C4B34 => 4, 0x424C4B35 => 5 }.freeze
34
+ ACTIONS = %w[skip block challenge warn].freeze
35
+
36
+ # A read-only uint32 little-endian view over a byte String, from a byte offset, `length` words long.
37
+ class Words
38
+ attr_reader :length
39
+
40
+ def initialize(buf, byte_off = 0, length = nil)
41
+ @buf = buf
42
+ @off = byte_off
43
+ @length = length || [(buf.bytesize - byte_off) / 4, 0].max # a trailing partial word is dropped, as the Uint32Array view does
44
+ end
45
+
46
+ def [](i) = @buf.unpack1("V", offset: @off + (4 * i))
47
+ def slice(start, len) = Words.new(@buf, @off + (4 * start), len)
48
+ def empty? = @length == 0
49
+ end
50
+
51
+ # A zero-filled section of `length` words: what an absent bitmap/index reads as.
52
+ class Zeros
53
+ attr_reader :length
54
+
55
+ def initialize(length)
56
+ @length = length
57
+ end
58
+
59
+ def [](_i) = 0
60
+ def empty? = @length == 0
61
+ end
62
+
63
+ EMPTY = Words.new("".b, 0, 0)
64
+
65
+ # A v4 side list. `empty` short-circuits the matcher on the (common) v3 snapshot.
66
+ RangeSet = Struct.new(:r4, :r6, :n6, :asn, :country, :paths_exact, :paths_prefix, :empty, keyword_init: true)
67
+
68
+ # The request a compiled condition reads. `ip6` is the parsed address words, or nil; `header`
69
+ # is called with an already lower-cased name, absent where the tap cannot read headers.
70
+ RuleRequest = Struct.new(:n4, :ip6, :asn, :country, :tlsx, :path, :ua, :header, keyword_init: true)
71
+
72
+ CompiledRule = Struct.new(:id, :action, :conds)
73
+
74
+ Snap = Struct.new(
75
+ :version, :format, :s4, :e4, :idx4, :bm4, :s6, :e6, :n6, :bm6, :asn_bm, :asn_extra,
76
+ :country, :tls, :paths_exact, :paths_prefix, :paths_regex, :allow, :challenge,
77
+ :rules, # v5 only; empty on v3/v4, and the matcher then skips them
78
+ keyword_init: true
79
+ )
80
+
81
+ def self.range_set(r4, r6, m)
82
+ m ||= {}
83
+ asn = (m["asn"] || []).to_set(&:to_i)
84
+ country = (m["country"] || []).to_set
85
+ exact = (m["pathsExact"] || []).to_set
86
+ prefix = (m["pathsPrefix"] || []).to_set
87
+ empty = r4.empty? && r6.empty? && asn.empty? && country.empty? && exact.empty? && prefix.empty?
88
+ RangeSet.new(r4: r4, r6: r6, n6: r6.length >> 3, asn: asn, country: country, paths_exact: exact, paths_prefix: prefix, empty: empty)
89
+ end
90
+
91
+ # Binary search over interleaved [start, end] uint32 pairs sorted by start.
92
+ def self.in_range4?(r, n)
93
+ lo = 0
94
+ hi = (r.length >> 1) - 1
95
+ return false if hi < 0
96
+
97
+ while lo < hi
98
+ m = (lo + hi + 1) >> 1
99
+ if r[m * 2] <= n
100
+ lo = m
101
+ else
102
+ hi = m - 1
103
+ end
104
+ end
105
+ n.between?(r[lo * 2], r[(lo * 2) + 1])
106
+ end
107
+
108
+ # Compares the 4 words at a[o..o+3] against the address words.
109
+ def self.cmp_words(a, o, w)
110
+ 4.times do |k|
111
+ x = a[o + k]
112
+ y = w[k]
113
+ return x < y ? -1 : 1 if x != y
114
+ end
115
+ 0
116
+ end
117
+
118
+ # Binary search over an interleaved [4-word start, 4-word end] side section.
119
+ def self.in_range6?(r, n, w)
120
+ return false if n < 1
121
+
122
+ lo = 0
123
+ hi = n - 1
124
+ while lo < hi
125
+ m = (lo + hi + 1) >> 1
126
+ if cmp_words(r, m * 8, w) <= 0
127
+ lo = m
128
+ else
129
+ hi = m - 1
130
+ end
131
+ end
132
+ o = lo * 8
133
+ cmp_words(r, o, w) <= 0 && cmp_words(r, o + 4, w) >= 0 # rubocop:disable Style/ComparableBetween -- two different comparisons
134
+ end
135
+
136
+ # A pattern this runtime rejects never matches, and never throws (fail open). Patterns are
137
+ # authored as JS regexes (the analyst validates them with `new RegExp`), so the JS spellings
138
+ # Onigmo reads differently are translated first — see js_to_onigmo; \d \w \b are ASCII in
139
+ # Ruby by default, as JS reads them.
140
+ def self.compile_regex(pattern)
141
+ Regexp.new(js_to_onigmo(pattern))
142
+ rescue RegexpError, TypeError, ArgumentError, EncodingError
143
+ nil
144
+ end
145
+
146
+ # The JS-only spellings a tenant is likely to author: `[^]` (any char) -> `[\s\S]`, `\cX` ->
147
+ # the control character, and the anchors: JS `^`/`$` bind the whole input while Ruby's are
148
+ # line anchors, so outside a class they become `\A`/`\z`. `(?<name>` is native to Onigmo.
149
+ # Anything else Ruby rejects still fails open.
150
+ def self.js_to_onigmo(pattern)
151
+ out = +""
152
+ i = 0
153
+ n = pattern.length
154
+ in_class = false
155
+ while i < n
156
+ ch = pattern[i]
157
+ if ch == "\\" && i + 1 < n
158
+ if pattern[i + 1] == "c" && i + 2 < n && pattern[i + 2].match?(/[A-Za-z]/)
159
+ out << Regexp.escape((pattern[i + 2].upcase.ord - 64).chr)
160
+ i += 3
161
+ next
162
+ end
163
+ out << pattern[i, 2]
164
+ i += 2
165
+ next
166
+ end
167
+ if in_class
168
+ in_class = ch != "]"
169
+ elsif ch == "["
170
+ if pattern[i, 3] == "[^]"
171
+ out << "[\\s\\S]"
172
+ i += 3
173
+ next
174
+ end
175
+ in_class = true
176
+ elsif ch == "^"
177
+ out << "\\A"
178
+ i += 1
179
+ next
180
+ elsif ch == "$"
181
+ out << "\\z"
182
+ i += 1
183
+ next
184
+ end
185
+ out << ch
186
+ i += 1
187
+ end
188
+ out
189
+ end
190
+
191
+ # A regex test that never raises: a value in an encoding the pattern cannot read (a binary
192
+ # PATH_INFO with high bytes, say) reads as "no match" rather than an exception on the request path.
193
+ def self.regex_hit?(rx, value)
194
+ rx.match?(value)
195
+ rescue StandardError
196
+ false
197
+ end
198
+
199
+ # The same envelope for the plain string ops: Puma hands every env value to Rack as
200
+ # ASCII-8BIT while a rule's needle arrives from JSON as UTF-8, and `include?` /
201
+ # `start_with?` across that pair raise Encoding::CompatibilityError once either side carries a
202
+ # high byte. The adapter scrubs its values, but the matcher must not depend on it: an
203
+ # exception here escapes match(), handle() falls back to INERT and a blocked client passes.
204
+ def self.text_hit?
205
+ yield
206
+ rescue StandardError
207
+ false
208
+ end
209
+
210
+ # ---------- custom rules (v5) ----------
211
+
212
+ # The string one condition reads, or nil when this request cannot answer the field.
213
+ # `header` is not here: it needs the condition's own name, so compile_cond builds its reader.
214
+ def self.field_value(f, r)
215
+ case f
216
+ when "asn" then r.asn&.to_s
217
+ when "country" then Camada.present(r.country)
218
+ when "tlsx" then Camada.present(r.tlsx)
219
+ when "path" then r.path
220
+ when "ua" then Camada.present(r.ua)
221
+ end
222
+ # an entity-plane field (bot.verified, rule) answers nil: never true here
223
+ end
224
+
225
+ # One condition -> a predicate. `sets` yields this rule's (v4, v6) section pair per ip
226
+ # condition, in condition order, so an ip condition consumes the next one.
227
+ def self.compile_cond(c, sets)
228
+ f = c["f"].to_s
229
+ op = c["op"].to_s
230
+ negate = op == "is_not" || op == "not_in"
231
+ # A header condition reads the request through the caller's getter. The name is lower-cased
232
+ # once, here; a tap that cannot read headers (no getter) and a header the request does not
233
+ # carry are both nil, and nil is false for every op — the rule simply does not fire (fail
234
+ # open, §A4). The getter is app code: one that raises, or answers something other than a
235
+ # String, is read as "no header" rather than allowed to take the whole match() down.
236
+ read =
237
+ if f == "header"
238
+ hname = c["name"].to_s.downcase
239
+ lambda do |r|
240
+ next nil if hname.empty? || r.header.nil?
241
+
242
+ v = begin
243
+ r.header.call(hname)
244
+ rescue StandardError
245
+ nil
246
+ end
247
+ v.is_a?(String) ? v : nil
248
+ end
249
+ else
250
+ ->(r) { field_value(f, r) }
251
+ end
252
+
253
+ if f == "ip"
254
+ p4, p6 = sets.empty? ? [EMPTY, EMPTY] : sets.shift
255
+ n6 = p6.length >> 3
256
+ return lambda do |r|
257
+ next false if r.n4 < 0 && r.ip6.nil? # no address: false for every op, negatives included
258
+
259
+ hit = (r.n4 >= 0 && in_range4?(p4, r.n4)) || (!r.ip6.nil? && in_range6?(p6, n6, r.ip6))
260
+ negate ? !hit : hit
261
+ end
262
+ end
263
+ raw = c["v"]
264
+ values = raw.is_a?(Array) ? raw.map(&:to_s) : [raw.to_s]
265
+ case op
266
+ when "matches"
267
+ rx = compile_regex(values[0])
268
+ lambda do |r|
269
+ v = read.call(r)
270
+ !v.nil? && !rx.nil? && regex_hit?(rx, v)
271
+ end
272
+ when "contains"
273
+ needle = values[0]
274
+ lambda do |r|
275
+ v = read.call(r)
276
+ !v.nil? && text_hit? { v.include?(needle) }
277
+ end
278
+ when "starts_with"
279
+ prefix = values[0]
280
+ lambda do |r|
281
+ v = read.call(r)
282
+ !v.nil? && text_hit? { v.start_with?(prefix) }
283
+ end
284
+ else # is | is_not | is_in | not_in
285
+ members = values.to_set
286
+ lambda do |r|
287
+ v = read.call(r)
288
+ next false if v.nil?
289
+
290
+ negate ? !members.include?(v) : members.include?(v)
291
+ end
292
+ end
293
+ end
294
+
295
+ # meta.rules + the repeated 14/15 sections -> predicates, in evaluation order. A rule this SDK
296
+ # cannot compile (unknown action, no conditions) is dropped rather than guessed at.
297
+ def self.compile_rules(meta, v4s, v6s)
298
+ out = []
299
+ (meta["rules"] || []).each_with_index do |r, i|
300
+ action = r["action"].to_s
301
+ next unless ACTIONS.include?(action) # an action this SDK does not know: ignore the rule rather than guess
302
+
303
+ v4 = v4s.select { |s| s[0] == i }
304
+ v6 = v6s.select { |s| s[0] == i }
305
+ sets = Array.new([v4.length, v6.length].max) do |k|
306
+ [k < v4.length ? v4[k].slice(1, v4[k].length - 1) : EMPTY, k < v6.length ? v6[k].slice(1, v6[k].length - 1) : EMPTY]
307
+ end
308
+ conds = begin
309
+ (r["conds"] || []).map { |c| compile_cond(c, sets) }
310
+ rescue StandardError
311
+ next # a malformed rule is dropped, never enforced
312
+ end
313
+ out << CompiledRule.new(r["id"].to_s, action, conds) unless conds.empty? # a rule with no conditions would match everything
314
+ end
315
+ out
316
+ end
317
+
318
+ # Parses a BLK container (a byte String, or a Words view into one) + meta into a Snap. Raises
319
+ # ArgumentError on a malformed container — callers keep the previous snapshot, exactly like
320
+ # the edge collector does.
321
+ def self.parse_snapshot(binary, meta)
322
+ u = binary.is_a?(Words) ? binary : Words.new(binary)
323
+ fmt = u.length >= 2 ? FORMATS[u[0]] : nil
324
+ raise ArgumentError, "camada: not a BLK3 snapshot" unless fmt
325
+
326
+ count = u[1]
327
+ sec = {}
328
+ rule4 = []
329
+ rule6 = []
330
+ raise ArgumentError, "camada: truncated BLK3 header" if u.length < 2 + (count * 3)
331
+
332
+ count.times do |i|
333
+ t = u[2 + (i * 3)]
334
+ off = u[3 + (i * 3)]
335
+ ln = u[4 + (i * 3)]
336
+ raise ArgumentError, "camada: truncated BLK3 section" if off + ln > u.length
337
+
338
+ s = u.slice(off, ln)
339
+ case t
340
+ when 14 then rule4 << s # repeated, one per ip condition: kept in container order
341
+ when 15 then rule6 << s
342
+ else sec[t] = s
343
+ end
344
+ end
345
+ s6 = sec.fetch(5, EMPTY)
346
+ Snap.new(
347
+ version: meta["version"].to_s,
348
+ format: fmt,
349
+ s4: sec.fetch(1, EMPTY), e4: sec.fetch(2, EMPTY), idx4: sec[3] || Zeros.new(65_537), bm4: sec[4] || Zeros.new(524_288),
350
+ s6: s6, e6: sec.fetch(6, EMPTY), n6: s6.length / 4, bm6: sec[7] || Zeros.new(524_288),
351
+ asn_bm: sec[8] || Zeros.new(131_072), asn_extra: sec.fetch(9, EMPTY),
352
+ country: (meta["country"] || []).to_set,
353
+ tls: (meta["tls"] || []).to_set,
354
+ paths_exact: (meta["pathsExact"] || []).to_set,
355
+ paths_prefix: (meta["pathsPrefix"] || []).to_set,
356
+ paths_regex: (meta["pathsRegex"] || []).filter_map { |p| compile_regex(p) },
357
+ allow: range_set(sec.fetch(10, EMPTY), sec.fetch(11, EMPTY), meta["allow"]),
358
+ challenge: range_set(sec.fetch(12, EMPTY), sec.fetch(13, EMPTY), meta["challenge"]),
359
+ rules: compile_rules(meta, rule4, rule6)
360
+ )
361
+ end
362
+ end
363
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "zlib"
6
+ require "stringio"
7
+
8
+ module Camada
9
+ # The one HTTP seam. The engine, the snapshot client and the event queue speak to the analyst
10
+ # through a transport (anything responding to #call(HttpRequest) -> HttpResponse), so tests
11
+ # inject an in-process fake and production uses Net::HTTP. A transport never raises: a network
12
+ # failure is a status-0 response, which every caller treats as "keep what we have".
13
+ HttpRequest = Struct.new(:method, :url, :headers, :body, :timeout_s, keyword_init: true)
14
+ HttpResponse = Struct.new(
15
+ :status, # 0 when the request never got an answer
16
+ :headers, # lower-cased names
17
+ :body, # a binary String
18
+ keyword_init: true
19
+ )
20
+
21
+ module Transport
22
+ # Net::HTTP over the stdlib, gzip-aware (GET /snapshot ships ~5 MB that gzips to a few KB).
23
+ # Net::HTTP negotiates and decodes gzip on its own as long as the caller does not set
24
+ # accept-encoding by hand (that switches decode_content off), so that header is dropped here
25
+ # and the response is inflated below should a server still label it.
26
+ def self.net_http(req)
27
+ uri = URI.parse(req.url)
28
+ http = Net::HTTP.new(uri.host, uri.port)
29
+ http.use_ssl = uri.scheme == "https"
30
+ http.open_timeout = req.timeout_s
31
+ http.read_timeout = req.timeout_s
32
+ http.write_timeout = req.timeout_s
33
+ r = Net::HTTPGenericRequest.new(req.method, !req.body.nil?, true, uri.request_uri)
34
+ (req.headers || {}).each { |k, v| r[k] = v unless k.downcase == "accept-encoding" }
35
+ r.body = req.body if req.body
36
+ res = http.start { |h| h.request(r) }
37
+ headers = {}
38
+ res.each_header { |k, v| headers[k.downcase] = v }
39
+ response(res.code.to_i, headers, (res.body || "").b)
40
+ rescue StandardError
41
+ HttpResponse.new(status: 0, headers: {}, body: "".b)
42
+ end
43
+
44
+ def self.response(status, headers, body)
45
+ if headers["content-encoding"].to_s.downcase == "gzip"
46
+ begin
47
+ body = Zlib::GzipReader.new(StringIO.new(body)).read.b
48
+ rescue StandardError
49
+ return HttpResponse.new(status: 0, headers: headers, body: "".b) # a body we cannot read is no answer at all
50
+ end
51
+ headers.delete("content-encoding")
52
+ end
53
+ HttpResponse.new(status: status, headers: headers, body: body)
54
+ end
55
+
56
+ DEFAULT = method(:net_http)
57
+ end
58
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Camada
4
+ # The SDK's wire identity: x-camada-sdk: @camada/ruby/<version>. This literal is the single
5
+ # source: camada.gemspec reads it at build, and the sibling drift guards (camada-backend,
6
+ # camada-web, camada-mkt) parse this file with /^\s*VERSION = "([^"]+)"/m the way they parse
7
+ # a package.json. Plain X.Y.Z only: the analyst's SDK_RE drops anything else.
8
+ VERSION = "0.1.0"
9
+ SDK_ID = "@camada/ruby/#{VERSION}".freeze
10
+ end
data/lib/camada.rb ADDED
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ # camada for Ruby: inline enforcement of your tenant snapshot (ordered custom rules, then
4
+ # allow, block, challenge), a first-party proof-of-work challenge and beacon, app-context events,
5
+ # and batched wire events shipped off the request path. Fails open by design.
6
+ #
7
+ # Quickstart (env: CAMADA_KEY, plus CAMADA_INGEST_URL in dev):
8
+ #
9
+ # # config.ru — any Rack app (Sinatra, Hanami, plain Rack)
10
+ # use Camada::Rack
11
+ # run App
12
+ #
13
+ # # Rails: nothing to add — the Railtie inserts Camada::Rack first
14
+ #
15
+ # # in a route
16
+ # Camada.script_tag(env) # the beacon tag for your HTML
17
+ # Camada.track(env, "login_failed", user: email) # an outcome your handler knows
18
+ # halt(*Camada.serve_challenge(env)) if ... # gate a route yourself
19
+ require_relative "camada/version"
20
+ require_relative "camada/constants"
21
+ require_relative "camada/config"
22
+ require_relative "camada/env"
23
+ require_relative "camada/redact"
24
+ require_relative "camada/ipparse"
25
+ require_relative "camada/ip"
26
+ require_relative "camada/guarded"
27
+ require_relative "camada/transport"
28
+ require_relative "camada/snapshot/parse"
29
+ require_relative "camada/snapshot/match"
30
+ require_relative "camada/snapshot/client"
31
+ require_relative "camada/events/build"
32
+ require_relative "camada/events/queue"
33
+ require_relative "camada/challenge/format"
34
+ require_relative "camada/challenge/verify"
35
+ require_relative "camada/challenge/page"
36
+ require_relative "camada/beacon_js"
37
+ require_relative "camada/engine"
38
+ require_relative "camada/body_proxy"
39
+ require_relative "camada/rack"
40
+ require_relative "camada/railtie"
41
+
42
+ module Camada
43
+ @default = nil
44
+ @default_lock = Mutex.new
45
+
46
+ class << self
47
+ # The lazy singleton wired from the environment on first use (what the middleware shares).
48
+ def default(**opts)
49
+ @default_lock.synchronize { @default ||= create_engine(**opts) }
50
+ end
51
+
52
+ # Replaces the default engine (stopping the old one) — for tests and explicit wiring.
53
+ def configure(**opts)
54
+ @default_lock.synchronize do
55
+ @default&.stop
56
+ @default = create_engine(**opts)
57
+ end
58
+ end
59
+
60
+ # Stops and forgets the default engine; the next request builds a new one (tests).
61
+ def reset!
62
+ @default_lock.synchronize do
63
+ @default&.stop
64
+ @default = nil
65
+ end
66
+ end
67
+
68
+ # The engine that produced a request context, else the default: what the helpers resolve through.
69
+ def engine_for(ctx)
70
+ engine_of(ctx) || default
71
+ end
72
+
73
+ # The helpers take the Rack env (Sinatra's `env`, Rails' `request.env`); the middleware left
74
+ # the request context in env["camada"]. Each stands down silently when the middleware did not run.
75
+ def script_tag(env)
76
+ ctx = ctx_of(env)
77
+ engine_for(ctx).script_tag(ctx)
78
+ end
79
+
80
+ def track(env, event, user: nil)
81
+ ctx = ctx_of(env)
82
+ engine_for(ctx).track(ctx, event, user: user)
83
+ end
84
+
85
+ # A Rack triple [status, headers, [body]] to answer with (Sinatra: `halt(*answer)`) until the
86
+ # browser holds a valid _cch, then nil.
87
+ def serve_challenge(env)
88
+ ctx = ctx_of(env)
89
+ engine_for(ctx).serve_challenge(ctx)&.to_rack
90
+ end
91
+
92
+ private
93
+
94
+ def ctx_of(env)
95
+ ctx = env.is_a?(Hash) ? env["camada"] : nil
96
+ ctx.is_a?(Hash) ? ctx : nil
97
+ end
98
+ end
99
+ end
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: camada
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - camada
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-14 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: 'camada SDK for Ruby: inline enforcement of your snapshot (ordered custom
14
+ rules, then allow, block, challenge), first-party beacon and proof-of-work challenge,
15
+ batched event shipping. A Rack middleware for any Rack app (Sinatra, Rails via the
16
+ Railtie, Hanami, plain Rack).'
17
+ email:
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - LICENSE
23
+ - README.md
24
+ - lib/camada.rb
25
+ - lib/camada/beacon_js.rb
26
+ - lib/camada/body_proxy.rb
27
+ - lib/camada/challenge/format.rb
28
+ - lib/camada/challenge/page.rb
29
+ - lib/camada/challenge/verify.rb
30
+ - lib/camada/config.rb
31
+ - lib/camada/constants.rb
32
+ - lib/camada/engine.rb
33
+ - lib/camada/env.rb
34
+ - lib/camada/events/build.rb
35
+ - lib/camada/events/queue.rb
36
+ - lib/camada/guarded.rb
37
+ - lib/camada/ip.rb
38
+ - lib/camada/ipparse.rb
39
+ - lib/camada/rack.rb
40
+ - lib/camada/railtie.rb
41
+ - lib/camada/redact.rb
42
+ - lib/camada/snapshot/client.rb
43
+ - lib/camada/snapshot/match.rb
44
+ - lib/camada/snapshot/parse.rb
45
+ - lib/camada/transport.rb
46
+ - lib/camada/version.rb
47
+ homepage: https://github.com/camada-app/camada-ruby
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ rubygems_mfa_required: 'true'
52
+ homepage_uri: https://github.com/camada-app/camada-ruby
53
+ source_code_uri: https://github.com/camada-app/camada-ruby
54
+ post_install_message:
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '3.1'
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 3.5.22
70
+ signing_key:
71
+ specification_version: 4
72
+ summary: 'camada SDK for Ruby: inline enforcement of your snapshot, first-party beacon
73
+ and challenge, batched events'
74
+ test_files: []