fingerly 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.
- checksums.yaml +7 -0
- data/LICENSE +202 -0
- data/README.md +402 -0
- data/lib/fingerly/client.rb +251 -0
- data/lib/fingerly/config.rb +146 -0
- data/lib/fingerly/errors.rb +95 -0
- data/lib/fingerly/models.rb +182 -0
- data/lib/fingerly/proxy.rb +430 -0
- data/lib/fingerly/redacted.rb +52 -0
- data/lib/fingerly/transport.rb +344 -0
- data/lib/fingerly/version.rb +6 -0
- data/lib/fingerly/webhooks.rb +126 -0
- data/lib/fingerly.rb +27 -0
- data/sig/fingerly.rbs +213 -0
- metadata +61 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5dbd638d3731d3062c062b00a80a0ef4904b486ecf96c91426b0f0ba9ff613cd
|
|
4
|
+
data.tar.gz: 1a4759ace195de23f5c08cbadd10a5df576ce0093897f32cfb291fa0441335b0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0da74c52537b8f8091458938f8057536521b295de0df1b13b9af832f4d8cc138365acd2356f425ff9148f9b3977c54ec8c7d9b49de5f336c991901f22aee13fb
|
|
7
|
+
data.tar.gz: 310aa51b0aa74788d7a694075e8802182c275663d8b38260cda66faec16b383e165b38da542dc414be5d1fff5f5e692ef7c8bbf6cd178412660d2ea7e65232ee
|
data/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
data/README.md
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
# fingerly-ruby
|
|
2
|
+
|
|
3
|
+
Server-side event reads, webhook verification and a same-origin proxy for
|
|
4
|
+
Ruby.
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
gem "fingerly", "~> 0.1"
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
require "fingerly"
|
|
12
|
+
|
|
13
|
+
fingerly = Fingerly::Client.new(secret_key: ENV.fetch("FINGERLY_SECRET_KEY"))
|
|
14
|
+
|
|
15
|
+
request_id = ARGV.fetch(0) # the requestId the browser SDK's identify() returned
|
|
16
|
+
event = fingerly.events.get(request_id)
|
|
17
|
+
page = fingerly.events.list(level: "high", limit: 50)
|
|
18
|
+
page.each { |row| puts "#{row.request_id} #{row.suspect_level}" }
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Event reads require `fly_sk_{production|staging|development}_` keys. Requests
|
|
22
|
+
go to `https://api.fingerly.io`; `endpoint:` overrides that for a local
|
|
23
|
+
API. Public, proxy, management, malformed and unsupported keys are rejected
|
|
24
|
+
before network access, by the constructor rather than by the first call.
|
|
25
|
+
Requests do not follow redirects. `get` and `list` each perform one request
|
|
26
|
+
and never retry.
|
|
27
|
+
|
|
28
|
+
Requires Ruby 3.2+, which is what the gemspec declares, and fiber-scheduler
|
|
29
|
+
support below needs nothing newer. No runtime dependencies: everything it
|
|
30
|
+
needs is in the standard library. Rack is a contract rather than a
|
|
31
|
+
dependency, so the proxy speaks it without loading it and cannot pin a version
|
|
32
|
+
against your own.
|
|
33
|
+
|
|
34
|
+
## There is no asynchronous client, and none is needed
|
|
35
|
+
|
|
36
|
+
The Python SDK ships two clients because ASGI and WSGI are two live server
|
|
37
|
+
standards, and a blocking call inside FastAPI stalls its event loop. Ruby has
|
|
38
|
+
one server interface, Rack, and every framework on it calls your application
|
|
39
|
+
synchronously.
|
|
40
|
+
|
|
41
|
+
The stack that is not thread-per-request is covered anyway. On Ruby 3.2+,
|
|
42
|
+
the floor this gem requires, `Net::HTTP` yields to a fiber scheduler, so under
|
|
43
|
+
Falcon or the `async` gem the blocking reads in this SDK hand the thread back
|
|
44
|
+
to the scheduler rather than holding it. Concurrent calls overlap without a
|
|
45
|
+
second API to choose between, and there are tests asserting exactly that under
|
|
46
|
+
both a fiber scheduler and plain threads.
|
|
47
|
+
|
|
48
|
+
Where there is no fiber scheduler, a call holds its thread until it answers,
|
|
49
|
+
for at most `timeout:`. That is what Puma and Unicorn expect. Do not call the
|
|
50
|
+
client from an event loop that is not a fiber scheduler, such as an
|
|
51
|
+
EventMachine reactor: there it would stall every connection the loop serves.
|
|
52
|
+
|
|
53
|
+
Size the thread pool for the slow case, not the average one: while Fingerly is
|
|
54
|
+
slow, every proxied request holds its thread for the whole `timeout:`. At 100
|
|
55
|
+
proxied requests a second with 1% of them running into a 5 second timeout, that
|
|
56
|
+
is 5 threads busy on average, before any fast request is served. Run more
|
|
57
|
+
threads than that across your Puma or Unicorn workers, lower `timeout:` for the
|
|
58
|
+
proxy, or serve with Falcon, where a waiting call holds no thread.
|
|
59
|
+
|
|
60
|
+
## Deadlines
|
|
61
|
+
|
|
62
|
+
`timeout:` bounds the whole exchange: connecting, sending, the response
|
|
63
|
+
headers and the body.
|
|
64
|
+
|
|
65
|
+
That takes care on this platform. `Net::HTTP` has `open_timeout`,
|
|
66
|
+
`read_timeout` and `write_timeout`, but each bounds a single operation: a
|
|
67
|
+
server that answers and then sends one byte a second would satisfy all three
|
|
68
|
+
forever, and so would one that sends a header line a second, because each
|
|
69
|
+
header line gets the full read timeout. The client shrinks the socket's own
|
|
70
|
+
timeouts to whatever is left of the deadline before each step, and one
|
|
71
|
+
`Timeout` around the whole exchange covers the header lines, so the bound is
|
|
72
|
+
tight rather than a multiple of the timeout. Under a fiber scheduler that
|
|
73
|
+
`Timeout` is the scheduler's own.
|
|
74
|
+
|
|
75
|
+
Idle connections are kept in one pool per process and destination, shared by
|
|
76
|
+
every client and proxy in it. `Net::HTTP` cannot be used by two threads or
|
|
77
|
+
fibers at once, so each exchange checks a connection out and hands it back
|
|
78
|
+
when it is done: a burst of a few hundred concurrent requests reuses its
|
|
79
|
+
sockets rather than opening one each, and a fiber that ends (Falcon runs one
|
|
80
|
+
per client connection) returns its socket instead of stranding it. At most
|
|
81
|
+
256 idle connections are kept per destination. A connection is reopened after
|
|
82
|
+
60 seconds, so DNS is asked again, and dropped after 15 seconds idle. One the
|
|
83
|
+
other end closed while it was idle is reopened and the request sent again,
|
|
84
|
+
but only for `GET`, because a `POST` that may already have arrived must never
|
|
85
|
+
be sent twice. A forked worker starts with an empty pool.
|
|
86
|
+
|
|
87
|
+
## Errors
|
|
88
|
+
|
|
89
|
+
Every runtime failure derives from `Fingerly::Error`. A bad option raises
|
|
90
|
+
`Fingerly::ConfigurationError`, which is an `ArgumentError`: a key that was
|
|
91
|
+
never valid is a programming mistake rather than something a rescue around the
|
|
92
|
+
call site can help with.
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
def decide(fingerly, request_id)
|
|
96
|
+
event = fingerly.events.get(request_id)
|
|
97
|
+
event.scored? && event.suspect_level == "low" ? "allow" : "review"
|
|
98
|
+
rescue Fingerly::APIError => e
|
|
99
|
+
return "retry later" if e.pending? # not decided yet: read again after e.retry_after
|
|
100
|
+
return "refuse" if e.event_not_found?
|
|
101
|
+
return "retry later" if e.status == 429
|
|
102
|
+
|
|
103
|
+
raise
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Every response outside 2xx, a redirect included, raises `Fingerly::APIError`.
|
|
108
|
+
It carries `status`, and optional `code`, `details`, `request_id` and
|
|
109
|
+
`retry_after`. The secret key is stripped from the first three, and the
|
|
110
|
+
response body is never echoed. `request_id` falls back to the `x-request-id`
|
|
111
|
+
header when the body is not an error envelope. `retry_after` is the
|
|
112
|
+
`Retry-After` header in whole seconds, from either of its forms, or `nil`
|
|
113
|
+
when there was none: on a `429` or a pending event, wait that long before
|
|
114
|
+
reading again.
|
|
115
|
+
|
|
116
|
+
A read answers `404` with one of two codes, each with its own predicate:
|
|
117
|
+
|
|
118
|
+
- `pending?`: `event_pending`, the request id was accepted and the event is
|
|
119
|
+
still being recorded. Nothing is decided yet: read again after
|
|
120
|
+
`retry_after` seconds (the API sends `Retry-After: 2`), or verify
|
|
121
|
+
asynchronously. Do not treat it as fraud.
|
|
122
|
+
- `event_not_found?`: `event_not_found`, the request id was never issued, or
|
|
123
|
+
the event is past retention.
|
|
124
|
+
|
|
125
|
+
The client never retries by itself. A deadline raises
|
|
126
|
+
`Fingerly::TimeoutError`; an unreachable API raises
|
|
127
|
+
`Fingerly::ConnectionError`.
|
|
128
|
+
|
|
129
|
+
No object in the SDK shows a credential. A client or proxy printed with
|
|
130
|
+
`inspect`, `to_s`, `pp`, `to_json` or YAML shows `[redacted]` in place of its
|
|
131
|
+
key, and `Marshal.dump` refuses it. The request handed to `client_ip` hides the
|
|
132
|
+
visitor's cookies and authorization header the same way.
|
|
133
|
+
|
|
134
|
+
## Unscored events
|
|
135
|
+
|
|
136
|
+
`event.suspect_score` is `nil` when the request was not scored. Never read nil
|
|
137
|
+
as zero: a visitor about whom nothing fired scores 0, while a submission that
|
|
138
|
+
was never enriched has no score at all. `scored?` states the distinction at
|
|
139
|
+
the call site.
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
unless event.scored?
|
|
143
|
+
# Decide on something else; do not treat it as clean.
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`event.raw` keeps the response body exactly as it arrived, so a field the API
|
|
148
|
+
adds later is reachable without a gem upgrade.
|
|
149
|
+
|
|
150
|
+
## Read an event you just identified
|
|
151
|
+
|
|
152
|
+
An event is usually readable as soon as identification answers. If it is
|
|
153
|
+
still being recorded, the read raises a `Fingerly::APIError` whose `pending?`
|
|
154
|
+
is true and whose `retry_after` says when to read again:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
# Returns the event, or nil while it is still being recorded.
|
|
158
|
+
def read_event(fingerly, request_id)
|
|
159
|
+
fingerly.events.get(request_id)
|
|
160
|
+
rescue Fingerly::APIError => e
|
|
161
|
+
raise unless e.pending?
|
|
162
|
+
|
|
163
|
+
# Not recorded yet, so nothing is decided: read again after e.retry_after
|
|
164
|
+
# seconds (2 when nil), or verify asynchronously.
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Retry a pending read a bounded number of times, for example from a background
|
|
170
|
+
job. `event_not_found?` means a request id that was never issued, or one past
|
|
171
|
+
retention, and is never pending.
|
|
172
|
+
|
|
173
|
+
The archived document arrives moments after the event itself, so check
|
|
174
|
+
`has_document?` rather than assuming it is there.
|
|
175
|
+
|
|
176
|
+
## Verify a backend action
|
|
177
|
+
|
|
178
|
+
Read the event with a server-only key. Check that its tag matches your action,
|
|
179
|
+
that it is recent, that its environment is correct, and that it was scored.
|
|
180
|
+
Apply your own policy to the server's verdict. A pending event (`pending?`)
|
|
181
|
+
is not a verdict: retry after `retry_after` seconds or verify asynchronously,
|
|
182
|
+
rather than refusing the action. Do not authorize an action using scores or
|
|
183
|
+
visitor identifiers supplied by the browser. Tags correlate events; they do
|
|
184
|
+
not replace your application's authentication or replay protection.
|
|
185
|
+
|
|
186
|
+
## Webhooks
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
class FingerlyWebhooksController < ActionController::API
|
|
190
|
+
def create
|
|
191
|
+
payload = request.raw_post
|
|
192
|
+
|
|
193
|
+
valid = Fingerly::Webhooks.verify(
|
|
194
|
+
secret: ENV.fetch("FINGERLY_WEBHOOK_SECRET"),
|
|
195
|
+
payload: payload,
|
|
196
|
+
timestamp: request.headers["x-fingerly-timestamp"],
|
|
197
|
+
signature: request.headers["x-fingerly-signature"]
|
|
198
|
+
)
|
|
199
|
+
return head :bad_request unless valid
|
|
200
|
+
|
|
201
|
+
event = Fingerly::Webhooks.parse(payload)
|
|
202
|
+
# Hand the work to your own job queue here, deduplicating on event.id.
|
|
203
|
+
Rails.logger.info("Fingerly webhook #{event.id} (#{event.type})")
|
|
204
|
+
head :no_content
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
# config/routes.rb
|
|
211
|
+
post "/webhooks/fingerly", to: "fingerly_webhooks#create"
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Verify the exact raw bytes before parsing them. `request.raw_post` is those
|
|
215
|
+
bytes; a body reconstructed from parsed parameters is not. Signatures cover
|
|
216
|
+
the timestamp and body. The default replay tolerance is five minutes; persist
|
|
217
|
+
processed `id` values to deduplicate deliveries. During signing-secret
|
|
218
|
+
rotation, any matching signature in the comma-separated header is accepted;
|
|
219
|
+
only the first 32 are read.
|
|
220
|
+
Malformed or missing input is a false result, never an exception, and a body
|
|
221
|
+
that is not valid UTF-8 verifies rather than raising on concatenation.
|
|
222
|
+
|
|
223
|
+
`WebhookEvent#data` stays a plain `Hash`, so a new event type needs no gem
|
|
224
|
+
upgrade.
|
|
225
|
+
|
|
226
|
+
## Same-origin proxy
|
|
227
|
+
|
|
228
|
+
Content blockers refuse browser requests to third-party APIs, and a refused
|
|
229
|
+
identification is a visitor never identified. Mount the proxy on a path of
|
|
230
|
+
your own site and those requests become first-party. The proxy key is what
|
|
231
|
+
lets that hop declare the visitor's real address, origin and user agent; a
|
|
232
|
+
public key alone never can, so nobody can spoof them.
|
|
233
|
+
|
|
234
|
+
Create a proxy in the dashboard (**Integration > Proxy keys**). It issues a
|
|
235
|
+
path such as `/k7x2qm4a`, and the proxy answers that one path. Rack is the one
|
|
236
|
+
interface every Ruby server and framework shares, so there is one middleware
|
|
237
|
+
and no per-framework variants.
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
# config/application.rb, inside `class Application < Rails::Application`
|
|
241
|
+
config.middleware.insert_before 0, Fingerly::Proxy,
|
|
242
|
+
proxy_key: ENV.fetch("FINGERLY_PROXY_KEY"),
|
|
243
|
+
path: "/k7x2qm4a",
|
|
244
|
+
client_ip: ->(request) { request.header("CF-Connecting-IP") || request.peer_host }
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Use `insert_before 0`, not `use`. `use` appends the proxy after Rails' own
|
|
248
|
+
middleware, which then rewrites its answers on the way out:
|
|
249
|
+
`ActionDispatch::RequestId` replaces Fingerly's `x-request-id` with a Rails
|
|
250
|
+
one, and `Rack::ETag` and `Rack::Runtime` add `etag` and `x-runtime`. At the
|
|
251
|
+
front of the stack the proxy answers before any of them, and before anything
|
|
252
|
+
that could redirect a signed-out visitor.
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
# config.ru, for Sinatra, Hanami or a bare Rack app
|
|
256
|
+
require "fingerly"
|
|
257
|
+
require_relative "app" # defines MyApp, your Sinatra or Rack application
|
|
258
|
+
|
|
259
|
+
use Fingerly::Proxy,
|
|
260
|
+
proxy_key: ENV.fetch("FINGERLY_PROXY_KEY"),
|
|
261
|
+
path: "/k7x2qm4a",
|
|
262
|
+
client_ip: ->(request) { request.header("CF-Connecting-IP") || request.peer_host }
|
|
263
|
+
run MyApp
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Put it first, ahead of authentication: the browser SDK sends no cookies and
|
|
267
|
+
carries no user, and a filter that redirected it to a sign-in page would break
|
|
268
|
+
identification for every signed-out visitor.
|
|
269
|
+
|
|
270
|
+
Then point the browser SDK at the same path. Both of its calls go there, with
|
|
271
|
+
nothing appended:
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
import { load } from '@fingerly/web-js'
|
|
275
|
+
|
|
276
|
+
const fingerly = await load({ apiKey: 'fly_pk_…', endpoints: '/k7x2qm4a' })
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`path` is optional where the framework already routes the path to the proxy,
|
|
280
|
+
as a Rails `mount` or a `Rack::URLMap` entry does. Omit it there: the mount has
|
|
281
|
+
already moved the path into `SCRIPT_NAME`. A mount matches a prefix, but the
|
|
282
|
+
proxy still answers only the mount's exact path: `/k7x2qm4a/admin` or
|
|
283
|
+
`/k7x2qm4a/` routed to it get `404`. Rails hands a mount the same `PATH_INFO`
|
|
284
|
+
with and without a trailing slash, so the proxy tells them apart by the raw
|
|
285
|
+
request line in `REQUEST_URI`, which Puma and Falcon provide.
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
# config/routes.rb
|
|
289
|
+
mount Fingerly::Proxy.new(
|
|
290
|
+
proxy_key: ENV.fetch("FINGERLY_PROXY_KEY"),
|
|
291
|
+
client_ip: ->(request) { request.header("CF-Connecting-IP") || request.peer_host }
|
|
292
|
+
) => "/k7x2qm4a"
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
As middleware it sees every request in the stack, so there `path` is required
|
|
296
|
+
and its absence is a `ConfigurationError` at start-up.
|
|
297
|
+
|
|
298
|
+
### What the proxy does
|
|
299
|
+
|
|
300
|
+
It answers only the configured path, matched exactly and case-sensitively:
|
|
301
|
+
`/k7x2qm4a/`, `//k7x2qm4a`, `/k7x2qm4a/.`, `/k7x2qm4a%2F` and `/k7x2qm4a/admin`
|
|
302
|
+
are not its own. Anything else is passed along to
|
|
303
|
+
your application, or answered `404` when the proxy has no application behind
|
|
304
|
+
it. On its own path, anything but `POST` gets `405`. Every request it accepts
|
|
305
|
+
goes to one URL, `https://api.fingerly.io/api/v1/ingest`, which tells the
|
|
306
|
+
identification from the report that follows it by the body. Nothing else is
|
|
307
|
+
reachable through it, event reads included: they need a secret key and stay
|
|
308
|
+
unreachable from a browser by construction. `api_version:` changes only the
|
|
309
|
+
version in that URL.
|
|
310
|
+
|
|
311
|
+
The browser must present a public key from the same environment as the proxy
|
|
312
|
+
key, or the request gets `401`. Bodies over 1 MiB get `413`,
|
|
313
|
+
enforced while reading rather than after buffering. That bounds what the proxy
|
|
314
|
+
holds. A server that buffers the whole request before calling the application,
|
|
315
|
+
as Puma does, has already read it by then, so bound request bodies there with
|
|
316
|
+
the server's or the load balancer's own limit as well. Falcon streams the body,
|
|
317
|
+
so there the proxy stops reading at the limit. Only the content type, the
|
|
318
|
+
visitor metadata Fingerly needs, and an `Idempotency-Key` are forwarded;
|
|
319
|
+
customer cookies and authorization headers never are. The proxy reports itself
|
|
320
|
+
upstream as `ruby/0.1.0` in `x-fingerly-proxy-version`, which the dashboard
|
|
321
|
+
shows per proxy.
|
|
322
|
+
|
|
323
|
+
When Fingerly cannot be reached, the visitor gets a gateway error rather than
|
|
324
|
+
your application's `500`: `504 Gateway timeout` when `timeout:` (5 seconds by
|
|
325
|
+
default) passes, and `502 Bad gateway` for anything else, whether the
|
|
326
|
+
connection was refused or reset, the name did not resolve, or the answer was
|
|
327
|
+
malformed, cut short or over 4 MiB. The whole answer is read before anything
|
|
328
|
+
is written back, so a truncated response is never relayed as a success. Each
|
|
329
|
+
failure logs one line, without a stack trace, to `logger:` when you pass one,
|
|
330
|
+
to `rack.logger` when your server provides it, and to standard error
|
|
331
|
+
otherwise.
|
|
332
|
+
|
|
333
|
+
Only `content-type`, `x-request-id`, `ratelimit-limit`, `ratelimit-remaining`
|
|
334
|
+
and `retry-after` are relayed back, plus `cache-control: no-store` set by the
|
|
335
|
+
proxy. A content blocker can match a response header by name, and an
|
|
336
|
+
allowlist keeps anything that names Fingerly off the response, whatever the
|
|
337
|
+
API adds later.
|
|
338
|
+
|
|
339
|
+
The middleware decides from the path alone before it reads anything. It sees
|
|
340
|
+
every request in the stack, and reading the body to find out whether one
|
|
341
|
+
belongs to Fingerly would have consumed it: under Rack 3 `rack.input` is not
|
|
342
|
+
required to rewind, so your application would receive an empty request rather
|
|
343
|
+
than an error.
|
|
344
|
+
|
|
345
|
+
`path` is the path within your application. It excludes anything a sub-URI
|
|
346
|
+
deployment puts in `SCRIPT_NAME`, because the proxy matches `PATH_INFO`, which
|
|
347
|
+
means the same thing wherever the application is deployed. Use the one the
|
|
348
|
+
dashboard issued rather than inventing one: blockers look for words such as
|
|
349
|
+
`fingerprint`, `tracking` and `analytics` in a path.
|
|
350
|
+
|
|
351
|
+
Relayed headers are lowercase and repeats joined with `", "`, which is the
|
|
352
|
+
one shape Rack 2 and Rack 3 both accept: Rack 3 refuses an uppercase name
|
|
353
|
+
outright, and Rack 2 refuses the array Rack 3 would rather have.
|
|
354
|
+
|
|
355
|
+
> **`client_ip` is the one thing you must get right.** Resolve it from a
|
|
356
|
+
> header your own infrastructure sets, such as your load balancer's or CDN's,
|
|
357
|
+
> and fall back to `request.peer_host`, the TCP peer, when it is absent, as in
|
|
358
|
+
> local development. Trust that header only when traffic can reach your
|
|
359
|
+
> application through that CDN alone; otherwise a visitor can set it. Never read
|
|
360
|
+
> the left-most `X-Forwarded-For` value: the visitor writes it.
|
|
361
|
+
|
|
362
|
+
`client_ip` must return exactly one IPv4 or IPv6 address. Anything else, `nil`
|
|
363
|
+
or an empty string, a list, a port, a prefix length, or an exception, is a
|
|
364
|
+
configuration mistake: the proxy sends nothing upstream, answers `500` with
|
|
365
|
+
"The client IP callback returned no valid address", and logs it once per
|
|
366
|
+
process rather than once per visitor.
|
|
367
|
+
|
|
368
|
+
## Development and release preparation
|
|
369
|
+
|
|
370
|
+
```sh
|
|
371
|
+
bundle install
|
|
372
|
+
bundle exec rake # tests, RuboCop and the RBS signatures
|
|
373
|
+
|
|
374
|
+
FINGERLY_CONTRACT_DIR=../../contract bundle exec ruby tools/sync_contract.rb
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
Tests run with Ruby's own warnings on, and every proxy response goes through
|
|
378
|
+
`Rack::Lint`, which holds it to the Rack 3 spec more strictly than any
|
|
379
|
+
assertion written by hand. The fake API is a real WEBrick server on a loopback
|
|
380
|
+
port rather than a stubbed `Net::HTTP`: a stub shares this SDK's assumptions
|
|
381
|
+
about framing, keep-alive and compression, so it cannot disprove any of them.
|
|
382
|
+
|
|
383
|
+
`sig/fingerly.rbs` describes the public surface. `rbs validate` proves the
|
|
384
|
+
signatures are consistent; `test/signatures_test.rb` proves they describe the
|
|
385
|
+
library that exists, which matters because several readers are defined by
|
|
386
|
+
metaprogramming.
|
|
387
|
+
|
|
388
|
+
The vendored synthetic contract snapshot makes tests independent of sibling
|
|
389
|
+
repositories. Its hashes are checked by `ContractTest`, so there is no
|
|
390
|
+
separate check step. Never put production secrets in fixtures.
|
|
391
|
+
|
|
392
|
+
The gemspec sets `allowed_push_host` to RubyGems, so a push to any other host
|
|
393
|
+
is refused.
|
|
394
|
+
|
|
395
|
+
## Source structure
|
|
396
|
+
|
|
397
|
+
`Client` holds the connection and the options; `Events` owns the reads and the
|
|
398
|
+
list filters, and `Event`, `EventPage` and `EventTrigger` are what they speak
|
|
399
|
+
in. `Webhooks` verifies signatures. `Proxy` holds every proxy decision, with
|
|
400
|
+
`ProxyRequest` as the shape `client_ip` is given. `Config` validates every
|
|
401
|
+
caller-supplied option before any network access, and `Transport` is the one
|
|
402
|
+
place that knows how to bound an exchange and reuse a socket.
|