basecradle 0.10.3 → 0.11.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 +4 -4
- data/CHANGELOG.md +90 -0
- data/README.md +86 -5
- data/lib/basecradle/api_object.rb +24 -4
- data/lib/basecradle/client.rb +21 -17
- data/lib/basecradle/contact_messages.rb +164 -0
- data/lib/basecradle/dashboard.rb +13 -1
- data/lib/basecradle/errors.rb +9 -1
- data/lib/basecradle/headers.rb +164 -0
- data/lib/basecradle/rendering.rb +71 -0
- data/lib/basecradle/version.rb +1 -1
- data/lib/basecradle/webhooks.rb +4 -163
- data/lib/basecradle.rb +6 -3
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 40d7b447c5bbeb18244e440069f09432d808525a96ef2aa1232ae560c3c41194
|
|
4
|
+
data.tar.gz: 12bcc5f74631bd1f48fa8fbe6ea0882283e03cddfcdf68336ed631e7a68e8325
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 235a1d21044f525ca69c2cffb0909ffc05e8dcd2e63da7d2d5db51f30b34a7eeecf73434a029fc80b08421c1e04dcc61d0461f92e657e27a6b140a19e5f378f0
|
|
7
|
+
data.tar.gz: aa1f71ee1a7efdde1ca609d0768e23efc4c9a2b0f63602e43411752f05ef470b5bfb9cbb0eb4c2c3df38b99b3ca89fedf7de302ff01af5eafd869cd82af8208c
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,94 @@ section**. The newest heading is always the version `lib/basecradle/version.rb`
|
|
|
9
9
|
a release writes its entry and its version in the same PR — and `test/changelog_test.rb`
|
|
10
10
|
fails CI if the two ever disagree.
|
|
11
11
|
|
|
12
|
+
## [0.11.0] - 2026-10-10
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **Contact messages and notes, the platform's first admin-only surface**
|
|
17
|
+
([#229](https://github.com/basecradle/basecradle-ruby/issues/229), mirroring
|
|
18
|
+
basecradle#663). All six new operations are covered:
|
|
19
|
+
- `bc.contact_messages`: newest first and auto-paginating, narrowed with
|
|
20
|
+
`.filter(status: "received" | "closed" | "spam")`, and `.get(uuid)` for one record.
|
|
21
|
+
- `contact_message.update_status(status)`: sets the triage verdict and updates the
|
|
22
|
+
object in place.
|
|
23
|
+
- `contact_message.add_note(body:)`: returns the new `BaseCradle::Note`.
|
|
24
|
+
- `bc.notes` (every note, newest first) and `bc.notes.get(uuid)`.
|
|
25
|
+
|
|
26
|
+
A `ContactMessage` is a flat record. It embeds its notes in full, its `user` is `nil`
|
|
27
|
+
for a visitor without an account, and `data` (the vendors' slots) is the platform's
|
|
28
|
+
plain `Hash`, so the vendors are not modeled. A `Note` has the same shape everywhere
|
|
29
|
+
it appears, and its `notable` names its subject's `type` and `uuid`.
|
|
30
|
+
- **`BaseCradle::NotAnAdminError`** (a `ForbiddenError`) for the new `not_an_admin`
|
|
31
|
+
code. Every one of the six operations raises it for a caller who is not an admin.
|
|
32
|
+
- **`bc.me.admin`**, the Dashboard's admin-only sixth section (`contact_messages_url`,
|
|
33
|
+
`notes_url`, `guide_url`). It is present only for an admin. Reading it as anyone else
|
|
34
|
+
raises `MissingFieldError`, like any other withheld field.
|
|
35
|
+
- **`BaseCradle::RequestHeaders`**, the shared class behind a recorded request's
|
|
36
|
+
headers. `WebhookEventHeaders` now subclasses it. Its lookups and render are
|
|
37
|
+
unchanged; the one difference is the wording of the `KeyError` for a missing header,
|
|
38
|
+
which now says "request" where it said "delivery". A contact message's `headers` is the new `ContactMessageHeaders`, which follows
|
|
39
|
+
the same rules: case-insensitive lookup, and a render that shows header names but
|
|
40
|
+
never their values. A visitor's request headers can carry that visitor's own
|
|
41
|
+
credentials, just as a webhook sender's can.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- **`binary_payload` maps to `BaseCradle::BinaryPayloadError`.** The API documents
|
|
46
|
+
this webhook-ingest code (415, a body that is not UTF-8 text) beside
|
|
47
|
+
`payload_too_large`, but the SDK's registry never listed it. A 415 therefore surfaced
|
|
48
|
+
as a bare `BaseCradle::Error` instead of its own class.
|
|
49
|
+
|
|
50
|
+
### Note
|
|
51
|
+
|
|
52
|
+
- **0.10.4 was never published.** Its version and changelog entry merged, but it was
|
|
53
|
+
never tagged, so its changes reach RubyGems for the first time in this release.
|
|
54
|
+
|
|
55
|
+
## [0.10.4] - 2026-09-30
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
|
|
59
|
+
- **The names-only render rule is one module, and a reflective test now asks it of every
|
|
60
|
+
class** ([#212](https://github.com/basecradle/basecradle-ruby/issues/212)). Every
|
|
61
|
+
renderable object in this SDK prints its field *names* and never its field values —
|
|
62
|
+
that is what keeps a `bc_uat_` token, an inbound sender's `Authorization` header and an
|
|
63
|
+
endpoint's `ingest_url` out of logs, REPL transcripts and exception messages. Until now
|
|
64
|
+
the rule was three hand-rolled copies of the same three methods with nothing asking the
|
|
65
|
+
question of a fourth class, which is precisely how 0.10.2's leak arrived:
|
|
66
|
+
`WebhookEventHeaders` was added as a `Hash` descendant, `Hash` brought its own render,
|
|
67
|
+
every header value printed, and the suite stayed green. `BaseCradle::RendersNamesOnly`
|
|
68
|
+
now supplies `inspect`, `to_s` and `pretty_print` to `ApiObject`, `Client` and
|
|
69
|
+
`WebhookEventHeaders`, and `test/basecradle/rendering_test.rb` walks the SDK's own
|
|
70
|
+
constants to assert every renderable class gets all three from it. **No existing render
|
|
71
|
+
changed**, `Client`'s included — this is the same output from one place instead of
|
|
72
|
+
three, plus the guard. No leak was found; this closes the shape of the last one.
|
|
73
|
+
- **A record interpolates as its field names instead of a heap address.** `ApiObject` had
|
|
74
|
+
no `to_s` or `pretty_print` of its own, so `"#{message}"` and `puts message` gave
|
|
75
|
+
`#<BaseCradle::Message:0x000074385e23d470>`. They now give
|
|
76
|
+
`#<BaseCradle::Message body, uuid>`, the same string `inspect` and `pp` already gave.
|
|
77
|
+
Nothing was leaking — a heap address is not a value — but two of the three doors on the
|
|
78
|
+
SDK's most common object belonged to `Kernel` rather than to the rule.
|
|
79
|
+
### Fixed
|
|
80
|
+
|
|
81
|
+
- **A record with mixed-type keys no longer raises from its own render.** `ApiObject`
|
|
82
|
+
sorted its field names with `sort`, which refuses `comparison of Symbol with String
|
|
83
|
+
failed` — reachable because the README documents rebuilding a model from a cached
|
|
84
|
+
record, and a cache layer that symbolizes some keys hands back exactly that. It was one
|
|
85
|
+
raising door before and would have been three after the change above, so it now sorts
|
|
86
|
+
by the names' string form, as `WebhookEventHeaders` always has.
|
|
87
|
+
- **A record with no fields renders as `#<BaseCradle::Message>`**, not
|
|
88
|
+
`#<BaseCradle::Message >` with a dangling space. Reachable whenever the API sends a
|
|
89
|
+
nested content object as `{}`.
|
|
90
|
+
|
|
91
|
+
- **The ActiveSupport harness renders the class 0.10.2 secured.** The child-process probe
|
|
92
|
+
excluded `Hash` descendants, which was a true statement about *serialization* (a
|
|
93
|
+
delivery's headers are a record and serialize) applied to a render question it does not
|
|
94
|
+
answer — so the one class that fix was written for was never rendered under real
|
|
95
|
+
ActiveSupport by the harness that exists for that. There is now a records bucket,
|
|
96
|
+
covered reflectively against every `Hash` descendant the SDK defines, asserting both
|
|
97
|
+
halves: the render stays names-only through all three doors, and the record still
|
|
98
|
+
serializes.
|
|
99
|
+
|
|
12
100
|
## [0.10.3] - 2026-09-30
|
|
13
101
|
|
|
14
102
|
### Security
|
|
@@ -565,6 +653,8 @@ the Python SDK's behavior in idiomatic Ruby. Zero runtime dependencies.
|
|
|
565
653
|
- **Quality bars** — a README-as-tested-doc harness (every example runs against a mocked
|
|
566
654
|
API) and a spec drift-guard (CI fails if the live API grows beyond the SDK).
|
|
567
655
|
|
|
656
|
+
[0.11.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.11.0
|
|
657
|
+
[0.10.4]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.4
|
|
568
658
|
[0.10.3]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.3
|
|
569
659
|
[0.10.2]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.2
|
|
570
660
|
[0.10.1]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.1
|
data/README.md
CHANGED
|
@@ -337,6 +337,56 @@ timeline = bc.timelines.create(name: "Incident response")
|
|
|
337
337
|
timeline.add_participant(nova)
|
|
338
338
|
```
|
|
339
339
|
|
|
340
|
+
## Contact messages & notes (admin-only)
|
|
341
|
+
|
|
342
|
+
**Admin-only.** Every call in this section raises `BaseCradle::NotAnAdminError` (a
|
|
343
|
+
`ForbiddenError`, code `not_an_admin`) for anyone who is not a platform admin. The SDK
|
|
344
|
+
sends the request either way, because the platform decides who is an admin. To check
|
|
345
|
+
first, use `bc.me.identity.admin?`; an admin's Dashboard also carries a sixth section,
|
|
346
|
+
`bc.me.admin`, with the URLs of both surfaces.
|
|
347
|
+
|
|
348
|
+
A **contact message** is what someone sent through the public contact page. It is
|
|
349
|
+
stored with the request's own details and with what the risk-assessment vendors said
|
|
350
|
+
about it. A **note** is an admin's dated, signed remark about a record. Notes are never
|
|
351
|
+
edited or deleted.
|
|
352
|
+
|
|
353
|
+
```ruby
|
|
354
|
+
require "basecradle"
|
|
355
|
+
|
|
356
|
+
bc = BaseCradle::Client.new
|
|
357
|
+
|
|
358
|
+
bc.contact_messages.filter(status: "received").each do |message| # newest first
|
|
359
|
+
puts [message.name, message.email_address, message.body].inspect
|
|
360
|
+
puts message.user&.handle # nil for a visitor without an account
|
|
361
|
+
puts message.honeypot_filled # the bot trap: the platform records it, never acts on it
|
|
362
|
+
puts message.data.keys.inspect # one self-describing slot per vendor
|
|
363
|
+
|
|
364
|
+
message.update_status("spam") if message.honeypot_filled # "received" | "closed" | "spam"
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
message = bc.contact_messages.get("019e7750-66ee-7b28-b052-67cdb5b10ca0")
|
|
368
|
+
note = message.add_note(body: "Looks genuine. Replied by email.")
|
|
369
|
+
puts note.notable.type # "contact_message": what the note is about
|
|
370
|
+
message.notes.each { |n| puts [n.user.handle, n.body].inspect } # notes come embedded
|
|
371
|
+
|
|
372
|
+
bc.notes.each { |n| puts n.body } # every note, across every subject, newest first
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
A contact message is a flat record with no `type`/`content` wrapper, so `message.uuid`
|
|
376
|
+
is its identity. `update_status` changes the triage verdict in any direction (reopen with
|
|
377
|
+
`"received"`). It updates the object you called it on and returns it. `add_note` returns
|
|
378
|
+
the new `BaseCradle::Note` and leaves `message.notes` as it was fetched, so call
|
|
379
|
+
`bc.contact_messages.get` again to read the record with the new note embedded. A note
|
|
380
|
+
body is not idempotent, so `add_note` is never retried automatically.
|
|
381
|
+
|
|
382
|
+
`data` is a plain `Hash`, exactly as the platform sent it. The SDK does not model the
|
|
383
|
+
vendors, which can change on the platform's side. Each slot names its `vendor`, the
|
|
384
|
+
`api` it called, its `docs`, and then exactly one of `answer`, `skipped` or `error`. The
|
|
385
|
+
scores do not all point the same way: Google's runs from 0.0 (bot) to 1.0 (human), and
|
|
386
|
+
the IP vendors' run from 0 to 100, higher being worse. `headers` follows the same rules
|
|
387
|
+
as a webhook delivery's: lookup ignores case, and a render shows header names but never
|
|
388
|
+
their values.
|
|
389
|
+
|
|
340
390
|
## Reading models, and serializing them
|
|
341
391
|
|
|
342
392
|
Every model is a read-only, wire-exact view of one API JSON object. Field readers mirror
|
|
@@ -472,13 +522,44 @@ spent, and no document comes out. (`Marshal` is the exception: Ruby itself refus
|
|
|
472
522
|
dump an `Enumerator` at all.) No token is exposed through any of them. Call `.first(n)`
|
|
473
523
|
or `.to_a` before handing a query to a renderer, and `.map(&:to_h)` too before a dumper.
|
|
474
524
|
|
|
475
|
-
|
|
476
|
-
dumps every instance variable, which would print the token into every exception message
|
|
477
|
-
and REPL transcript that touched a client:
|
|
525
|
+
### Renders show names, never values
|
|
478
526
|
|
|
527
|
+
Every object in this SDK prints its **field names** and none of its field values, through
|
|
528
|
+
all three doors a render arrives by — `inspect` (`p`, a REPL echo), `to_s` (string
|
|
529
|
+
interpolation, `puts`) and `pretty_print` (`pp`). That is what keeps your `bc_uat_` token,
|
|
530
|
+
an inbound sender's `Authorization` header and an endpoint's `ingest_url` out of logs,
|
|
531
|
+
exception messages and transcripts:
|
|
532
|
+
|
|
533
|
+
```ruby
|
|
534
|
+
bc = BaseCradle::Client.new
|
|
535
|
+
|
|
536
|
+
bc.inspect # => "#<BaseCradle::Client base_url=\"https://basecradle.com\" token=[REDACTED]>"
|
|
537
|
+
"#{bc}" # => "#<BaseCradle::Client base_url=\"https://basecradle.com\" token=[REDACTED]>"
|
|
538
|
+
|
|
539
|
+
# A record, built here from a wire hash the way a cache round-trip hands one back.
|
|
540
|
+
message = BaseCradle::Message.new(
|
|
541
|
+
{ "uuid" => "019e7750-66ee-7c31-9a7f-2b6d5e8a9c04",
|
|
542
|
+
"actor" => { "handle" => "nova" },
|
|
543
|
+
"content" => { "body" => "the body text" } },
|
|
544
|
+
client: bc
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
message.inspect # => "#<BaseCradle::Message actor, content, uuid>"
|
|
548
|
+
"#{message}" # => "#<BaseCradle::Message actor, content, uuid>"
|
|
549
|
+
message["content"]["body"] # => "the body text" — reads are untouched
|
|
550
|
+
|
|
551
|
+
headers = BaseCradle::WebhookEventHeaders.new(
|
|
552
|
+
{ "Content-Type" => "application/json", "Authorization" => "Bearer the-senders-secret" }
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
headers.inspect # => "#<BaseCradle::WebhookEventHeaders Authorization, Content-Type>"
|
|
556
|
+
headers["authorization"] # => "Bearer the-senders-secret" — still readable, and case-folded
|
|
479
557
|
```
|
|
480
|
-
|
|
481
|
-
|
|
558
|
+
|
|
559
|
+
The values are always there to *read* — `message["body"]`, `message.to_h`,
|
|
560
|
+
`event.content.headers["Authorization"]`, `to_json` — they are simply never what a render
|
|
561
|
+
prints. If you want a record's contents in a log, log `message.to_h` and decide that
|
|
562
|
+
deliberately.
|
|
482
563
|
|
|
483
564
|
## Development
|
|
484
565
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
require_relative "errors"
|
|
6
|
+
require_relative "rendering"
|
|
6
7
|
|
|
7
8
|
module BaseCradle
|
|
8
9
|
# The uuid for a value that may be a model object or a uuid string. A model's identity
|
|
@@ -36,6 +37,10 @@ module BaseCradle
|
|
|
36
37
|
# Objects built by a client carry a reference to it, so resource verbs added in later
|
|
37
38
|
# releases (e.g. +timeline.lock+) can act on the platform.
|
|
38
39
|
class ApiObject
|
|
40
|
+
# A record renders as its field names — never its values, which are whatever the API
|
|
41
|
+
# sent (a webhook endpoint's ingest_url among them).
|
|
42
|
+
include RendersNamesOnly
|
|
43
|
+
|
|
39
44
|
def initialize(data, client: nil)
|
|
40
45
|
@data = data
|
|
41
46
|
@client = client
|
|
@@ -112,12 +117,27 @@ module BaseCradle
|
|
|
112
117
|
[ self.class, @data ].hash
|
|
113
118
|
end
|
|
114
119
|
|
|
115
|
-
def inspect
|
|
116
|
-
"#<#{self.class} #{@data.keys.sort.join(', ')}>"
|
|
117
|
-
end
|
|
118
|
-
|
|
119
120
|
private
|
|
120
121
|
|
|
122
|
+
# The record's wire field names, sorted — never their values. A record holds whatever
|
|
123
|
+
# the API sent, which for a webhook endpoint includes its ingest_url.
|
|
124
|
+
#
|
|
125
|
+
# Sorted by their string form, not by `sort`: a record can be rebuilt from a cached
|
|
126
|
+
# hash (the README says so), and a caller whose cache layer symbolized some keys hands
|
|
127
|
+
# us mixed types, which `sort` refuses with `comparison of Symbol with String failed`.
|
|
128
|
+
# That used to raise from `inspect` alone and now would raise from interpolation and
|
|
129
|
+
# `pp` too — a render that blows up a log line is worse than the disorder it avoids.
|
|
130
|
+
# RequestHeaders#render_body (formerly WebhookEventHeaders') has always done this, for
|
|
131
|
+
# the same reason.
|
|
132
|
+
#
|
|
133
|
+
# nil, not "", for a record with no fields: the module renders nil as the bare class,
|
|
134
|
+
# and `"#<BaseCradle::Message >"` with its dangling space was never intended output.
|
|
135
|
+
def render_body
|
|
136
|
+
return nil if @data.empty?
|
|
137
|
+
|
|
138
|
+
@data.keys.sort_by(&:to_s).join(", ")
|
|
139
|
+
end
|
|
140
|
+
|
|
121
141
|
# The client this object came from — required by verbs that call the API (later releases).
|
|
122
142
|
def require_client
|
|
123
143
|
return @client if @client
|
data/lib/basecradle/client.rb
CHANGED
|
@@ -4,9 +4,11 @@ require "json"
|
|
|
4
4
|
require "net/http"
|
|
5
5
|
require "uri"
|
|
6
6
|
|
|
7
|
+
require_relative "contact_messages"
|
|
7
8
|
require_relative "dashboard"
|
|
8
9
|
require_relative "errors"
|
|
9
10
|
require_relative "items"
|
|
11
|
+
require_relative "rendering"
|
|
10
12
|
require_relative "serialization"
|
|
11
13
|
require_relative "sessions"
|
|
12
14
|
require_relative "timelines"
|
|
@@ -25,8 +27,10 @@ module BaseCradle
|
|
|
25
27
|
# endpoints added before the SDK wraps them (the API is additive-only).
|
|
26
28
|
class Client
|
|
27
29
|
# A client is a connection holding a live credential, never a record: serializing one
|
|
28
|
-
# raises rather than write the token into a response body or a log line
|
|
30
|
+
# raises rather than write the token into a response body or a log line, and rendering
|
|
31
|
+
# one prints the marker below rather than the token.
|
|
29
32
|
include NotSerializable
|
|
33
|
+
include RendersNamesOnly
|
|
30
34
|
|
|
31
35
|
DEFAULT_BASE_URL = "https://basecradle.com"
|
|
32
36
|
DEFAULT_TIMEOUT = 30
|
|
@@ -89,6 +93,8 @@ module BaseCradle
|
|
|
89
93
|
@webhook_events = WebhookEventsResource.new(self)
|
|
90
94
|
@sessions = SessionsResource.new(self)
|
|
91
95
|
@users = UsersResource.new(self)
|
|
96
|
+
@contact_messages = ContactMessagesResource.new(self)
|
|
97
|
+
@notes = NotesResource.new(self)
|
|
92
98
|
end
|
|
93
99
|
|
|
94
100
|
# Your timelines — iterable (auto-paginating, newest first), with create/get.
|
|
@@ -103,6 +109,10 @@ module BaseCradle
|
|
|
103
109
|
# The directory of other peers, and the trust handshake.
|
|
104
110
|
attr_reader :users
|
|
105
111
|
|
|
112
|
+
# Admin-only: what the public contact page received, and the notes admins write about
|
|
113
|
+
# it. A non-admin's call raises NotAnAdminError (see ContactMessagesResource).
|
|
114
|
+
attr_reader :contact_messages, :notes
|
|
115
|
+
|
|
106
116
|
# Mint a fresh token via POST /session and return an authenticated client.
|
|
107
117
|
#
|
|
108
118
|
# The minted token is on the returned client as +#token+ — save it; it is never
|
|
@@ -197,22 +207,6 @@ module BaseCradle
|
|
|
197
207
|
handle(response)
|
|
198
208
|
end
|
|
199
209
|
|
|
200
|
-
# Never the token. Ruby's default +inspect+ dumps every ivar, which would print the
|
|
201
|
-
# credential into every exception message, REPL transcript and +p+ call that touched
|
|
202
|
-
# a client — the same leak as serializing one, through a different door. The
|
|
203
|
-
# redaction marker is fixed text, so what it stands for cannot be guessed from its
|
|
204
|
-
# length. +to_s+ is the same string: string interpolation is the door people reach
|
|
205
|
-
# for without thinking about it.
|
|
206
|
-
def inspect
|
|
207
|
-
"#<#{self.class} base_url=#{@base_url.inspect} token=[REDACTED]>"
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
# Late-bound rather than `alias to_s inspect`, so a subclass that redacts more in
|
|
211
|
-
# inspect is not bypassed by interpolation.
|
|
212
|
-
def to_s
|
|
213
|
-
inspect
|
|
214
|
-
end
|
|
215
|
-
|
|
216
210
|
# The shared low-level send: returns the Net::HTTPResponse or raises APIConnectionError.
|
|
217
211
|
def self.perform(uri, request, timeout)
|
|
218
212
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
@@ -243,6 +237,16 @@ module BaseCradle
|
|
|
243
237
|
|
|
244
238
|
private
|
|
245
239
|
|
|
240
|
+
# Never the token. Ruby's default render dumps every ivar, which would print the
|
|
241
|
+
# credential into every exception message, REPL transcript, +pp+ and string
|
|
242
|
+
# interpolation that touched a client — the same leak as serializing one, through
|
|
243
|
+
# three different doors. The marker is fixed text, so what it stands for cannot be
|
|
244
|
+
# guessed from its length. The base URL is not a secret and is the one thing worth
|
|
245
|
+
# knowing about a client at a glance.
|
|
246
|
+
def render_body
|
|
247
|
+
"base_url=#{@base_url.inspect} token=[REDACTED]"
|
|
248
|
+
end
|
|
249
|
+
|
|
246
250
|
# Why every serializer refuses — +to_json+ / +as_json+, +Marshal.dump+, +to_yaml+
|
|
247
251
|
# (see BaseCradle::NotSerializable). A client is not a record, and the thing it would
|
|
248
252
|
# emit is a live credential.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "api_object"
|
|
4
|
+
require_relative "headers"
|
|
5
|
+
require_relative "pagination"
|
|
6
|
+
require_relative "serialization"
|
|
7
|
+
require_relative "user"
|
|
8
|
+
|
|
9
|
+
module BaseCradle
|
|
10
|
+
# Contact messages and notes are the platform's first **admin-only** surface. Every call
|
|
11
|
+
# in this file answers a non-admin with +NotAnAdminError+ (403, +not_an_admin+) — the
|
|
12
|
+
# SDK sends the request regardless, because the platform, not this library, decides who
|
|
13
|
+
# is an admin. +bc.me.admin+ is present only for an admin, if you want to ask first.
|
|
14
|
+
|
|
15
|
+
# --- models ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
# What a note is about: its subject's record type and uuid. Typed, because a note can be
|
|
18
|
+
# about any kind of record — today it is always a +"contact_message"+.
|
|
19
|
+
class Notable < ApiObject
|
|
20
|
+
attribute :type
|
|
21
|
+
attribute :uuid
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# An admin's dated, signed remark about a record. A note **never changes**: there is no
|
|
25
|
+
# update and no delete, for anyone. One shape everywhere a note appears — fetched alone,
|
|
26
|
+
# listed, or embedded on its subject (+contact_message.notes+).
|
|
27
|
+
class Note < ApiObject
|
|
28
|
+
attribute :uuid
|
|
29
|
+
attribute :body
|
|
30
|
+
attribute :user, wrap: User # the author, nested-actor form
|
|
31
|
+
attribute :notable, wrap: Notable
|
|
32
|
+
attribute :created_at
|
|
33
|
+
attribute :updated_at
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A contact-page submission's request headers, exactly as the visitor's browser sent
|
|
37
|
+
# them — read through RequestHeaders' rules: case-insensitive lookup, and a render of
|
|
38
|
+
# names and never values, because a visitor's request can carry its own credentials.
|
|
39
|
+
class ContactMessageHeaders < RequestHeaders; end
|
|
40
|
+
|
|
41
|
+
# What someone sent through the public contact page, stored with everything the request
|
|
42
|
+
# told the platform and, after the fact, what the risk-assessment vendors said about it.
|
|
43
|
+
# A top-level record, so flat (no +type+ / +content+): +contact_message.uuid+ is its
|
|
44
|
+
# identity, and it embeds its notes in full.
|
|
45
|
+
#
|
|
46
|
+
# Nothing is ever rejected on a score — the record is the data, and an admin decides,
|
|
47
|
+
# with +update_status+ and +add_note+.
|
|
48
|
+
class ContactMessage < ApiObject
|
|
49
|
+
attribute :uuid
|
|
50
|
+
attribute :name
|
|
51
|
+
attribute :email_address
|
|
52
|
+
attribute :body
|
|
53
|
+
# "received" (the default) | "closed" | "spam" — the triage verdict, set with
|
|
54
|
+
# +update_status+. Treat an unrecognized value as forward-compatible, never an error.
|
|
55
|
+
attribute :status
|
|
56
|
+
# The signed-in user who submitted it (nested-actor form), or +nil+ for a visitor
|
|
57
|
+
# without an account.
|
|
58
|
+
attribute :user, wrap: User
|
|
59
|
+
attribute :ip_address
|
|
60
|
+
attribute :user_agent
|
|
61
|
+
attribute :headers, wrap: ContactMessageHeaders
|
|
62
|
+
attribute :honeypot_filled
|
|
63
|
+
attribute :fill_seconds # how long the form took to fill, or nil
|
|
64
|
+
# One self-describing slot per vendor (+vendor+, +api+, +docs+, +fetched_at+,
|
|
65
|
+
# +attempts+, then exactly one of +answer+ / +skipped+ / +error+). A plain Hash, read
|
|
66
|
+
# as the platform sent it — the vendors are the platform's to add and change, so the
|
|
67
|
+
# SDK models none of them. Note the scores do not all run the same way: see
|
|
68
|
+
# https://basecradle.com/docs/api.md#contact-messages.
|
|
69
|
+
attribute :data
|
|
70
|
+
attribute :notes, wrap: Note
|
|
71
|
+
attribute :created_at
|
|
72
|
+
attribute :updated_at
|
|
73
|
+
|
|
74
|
+
# Set the triage verdict: +"received"+, +"closed"+ or +"spam"+. It moves in any
|
|
75
|
+
# direction — reopening a closed message is +update_status("received")+.
|
|
76
|
+
#
|
|
77
|
+
# Live object: the API returns the whole contact message, and this object reads it from
|
|
78
|
+
# here on (+status+ and +updated_at+ included), then returns +self+. Raises
|
|
79
|
+
# +ValidationError+ (422) for a status outside the three.
|
|
80
|
+
def update_status(status)
|
|
81
|
+
response = require_client.request("PATCH", "/contact_messages/#{uuid}/status",
|
|
82
|
+
json: { "contact_message" => { "status" => status } })
|
|
83
|
+
@data = response.fetch("contact_message")
|
|
84
|
+
self
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Write a note about this contact message, and return it as a +Note+ (you are its
|
|
88
|
+
# author). Raises +ValidationError+ (422) for a blank body.
|
|
89
|
+
#
|
|
90
|
+
# This object's own +notes+ is not touched: it is the record as fetched, and the API
|
|
91
|
+
# answers with the note alone. Fetch the contact message again
|
|
92
|
+
# (+bc.contact_messages.get(uuid)+) to read it with the new note embedded.
|
|
93
|
+
#
|
|
94
|
+
# Never auto-retried — the notes endpoint takes no +Idempotency-Key+, so a resend after
|
|
95
|
+
# a lost response could write the note twice.
|
|
96
|
+
def add_note(body:)
|
|
97
|
+
response = require_client.request("POST", "/contact_messages/#{uuid}/notes",
|
|
98
|
+
json: { "note" => { "body" => body } })
|
|
99
|
+
Note.new(response.fetch("note"), client: @client)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# --- collections ----------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
# Every contact message, newest first, auto-paginating — narrow with +.filter(status:)+,
|
|
106
|
+
# or fetch one by uuid. Admin-only.
|
|
107
|
+
#
|
|
108
|
+
# bc.contact_messages.filter(status: "received").each do |message|
|
|
109
|
+
# message.update_status("spam") if message.honeypot_filled
|
|
110
|
+
# end
|
|
111
|
+
class ContactMessagesResource
|
|
112
|
+
include Enumerable
|
|
113
|
+
include NotSerializableCollection
|
|
114
|
+
|
|
115
|
+
def initialize(client, filters: {})
|
|
116
|
+
@client = client
|
|
117
|
+
@filters = filters
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def each(&block)
|
|
121
|
+
return enum_for(:each) unless block_given?
|
|
122
|
+
|
|
123
|
+
Paginator.new(@client, "/contact_messages", envelope_key: "contact_messages",
|
|
124
|
+
model: ContactMessage, params: @filters).each(&block)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# A new lazy resource narrowed by status: +"received"+, +"closed"+ or +"spam"+. Any
|
|
128
|
+
# other value is the API's to refuse (+InvalidFilterError+, 400).
|
|
129
|
+
def filter(status: nil)
|
|
130
|
+
filters = @filters.dup
|
|
131
|
+
filters["status"] = status unless status.nil?
|
|
132
|
+
self.class.new(@client, filters: filters)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Fetch one contact message by its uuid.
|
|
136
|
+
def get(uuid)
|
|
137
|
+
ContactMessage.new(@client.request("GET", "/contact_messages/#{uuid}").fetch("contact_message"),
|
|
138
|
+
client: @client)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Every note, across every subject, newest first, auto-paginating — or fetch one by uuid.
|
|
143
|
+
# Admin-only. Notes are written from their subject (+contact_message.add_note+), never
|
|
144
|
+
# from here.
|
|
145
|
+
class NotesResource
|
|
146
|
+
include Enumerable
|
|
147
|
+
include NotSerializableCollection
|
|
148
|
+
|
|
149
|
+
def initialize(client)
|
|
150
|
+
@client = client
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def each(&block)
|
|
154
|
+
return enum_for(:each) unless block_given?
|
|
155
|
+
|
|
156
|
+
Paginator.new(@client, "/notes", envelope_key: "notes", model: Note).each(&block)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Fetch one note by its uuid.
|
|
160
|
+
def get(uuid)
|
|
161
|
+
Note.new(@client.request("GET", "/notes/#{uuid}").fetch("note"), client: @client)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
data/lib/basecradle/dashboard.rb
CHANGED
|
@@ -34,6 +34,13 @@ module BaseCradle
|
|
|
34
34
|
attribute :change_password_url
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# The operator's surfaces — present on the Dashboard only for an admin.
|
|
38
|
+
class DashboardAdmin < ApiObject
|
|
39
|
+
attribute :contact_messages_url
|
|
40
|
+
attribute :notes_url
|
|
41
|
+
attribute :guide_url
|
|
42
|
+
end
|
|
43
|
+
|
|
37
44
|
# One official SDK: where its code lives and where to install it from.
|
|
38
45
|
class DashboardSdk < ApiObject
|
|
39
46
|
attribute :repository
|
|
@@ -58,12 +65,17 @@ module BaseCradle
|
|
|
58
65
|
end
|
|
59
66
|
|
|
60
67
|
# Who am I, what is this place, where is everything — the answer every freshly-woken
|
|
61
|
-
# peer asks first. Identity · environment · interaction · account · documentation
|
|
68
|
+
# peer asks first. Identity · environment · interaction · account · documentation, and
|
|
69
|
+
# for an admin a sixth section, admin.
|
|
62
70
|
class Dashboard < ApiObject
|
|
63
71
|
attribute :identity, wrap: User
|
|
64
72
|
attribute :environment, wrap: DashboardEnvironment
|
|
65
73
|
attribute :interaction, wrap: DashboardInteraction
|
|
66
74
|
attribute :account, wrap: DashboardAccount
|
|
75
|
+
# Present only for an admin — so the Dashboard's shape is how a peer learns its own
|
|
76
|
+
# role. Reading it as anyone else raises MissingFieldError rather than guessing;
|
|
77
|
+
# branch on +me.identity.admin?+ instead.
|
|
78
|
+
attribute :admin, wrap: DashboardAdmin
|
|
67
79
|
attribute :documentation, wrap: DashboardDocumentation
|
|
68
80
|
end
|
|
69
81
|
end
|
data/lib/basecradle/errors.rb
CHANGED
|
@@ -80,6 +80,9 @@ module BaseCradle
|
|
|
80
80
|
# author (an admin may act on any task).
|
|
81
81
|
class NotTaskAuthorError < ForbiddenError; end
|
|
82
82
|
|
|
83
|
+
# +not_an_admin+ — the surface is admin-only (contact messages and notes).
|
|
84
|
+
class NotAnAdminError < ForbiddenError; end
|
|
85
|
+
|
|
83
86
|
# --- 404 --------------------------------------------------------------------------------
|
|
84
87
|
|
|
85
88
|
# +not_found+ — no record exists for the given UUID (or it is hidden from you).
|
|
@@ -145,6 +148,9 @@ module BaseCradle
|
|
|
145
148
|
# +payload_too_large+ — webhook ingest: the request body exceeds the maximum size.
|
|
146
149
|
class PayloadTooLargeError < Error; end
|
|
147
150
|
|
|
151
|
+
# +binary_payload+ — webhook ingest: the request body is not UTF-8 text (HTTP 415).
|
|
152
|
+
class BinaryPayloadError < Error; end
|
|
153
|
+
|
|
148
154
|
# --- the code => class registry ----------------------------------------------------------
|
|
149
155
|
|
|
150
156
|
# Every API error is an RFC 9457 application/problem+json document with a stable,
|
|
@@ -158,6 +164,7 @@ module BaseCradle
|
|
|
158
164
|
"not_timeline_owner" => NotTimelineOwnerError,
|
|
159
165
|
"timeline_locked" => TimelineLockedError,
|
|
160
166
|
"not_task_author" => NotTaskAuthorError,
|
|
167
|
+
"not_an_admin" => NotAnAdminError,
|
|
161
168
|
"not_found" => NotFoundError,
|
|
162
169
|
"task_not_pending" => TaskNotPendingError,
|
|
163
170
|
"validation_failed" => ValidationError,
|
|
@@ -167,7 +174,8 @@ module BaseCradle
|
|
|
167
174
|
"invalid_cursor" => InvalidCursorError,
|
|
168
175
|
"invalid_filter" => InvalidFilterError,
|
|
169
176
|
"endpoint_disabled" => EndpointDisabledError,
|
|
170
|
-
"payload_too_large" => PayloadTooLargeError
|
|
177
|
+
"payload_too_large" => PayloadTooLargeError,
|
|
178
|
+
"binary_payload" => BinaryPayloadError
|
|
171
179
|
}.freeze
|
|
172
180
|
|
|
173
181
|
class Error
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rendering"
|
|
4
|
+
|
|
5
|
+
module BaseCradle
|
|
6
|
+
# A recorded HTTP request's headers: the wire's own spelling, looked up
|
|
7
|
+
# case-insensitively. The platform records two kinds of inbound request verbatim — a
|
|
8
|
+
# webhook delivery (+WebhookEventHeaders+) and a contact-page submission
|
|
9
|
+
# (+ContactMessageHeaders+) — and both read through this one class's rules.
|
|
10
|
+
#
|
|
11
|
+
# A +Hash+ of exactly what the wire carried — one pair per header, +Content-Type+ and
|
|
12
|
+
# +Content-Length+ included — so iterating, +keys+, +to_h+, +to_json+ and +==+ all read
|
|
13
|
+
# the platform's own spelling and nothing is renamed. Only *lookup* folds case, because
|
|
14
|
+
# header names are case-insensitive by RFC and the platform does not preserve the
|
|
15
|
+
# sender's casing: it stores names canonicalized to Title-Case per segment
|
|
16
|
+
# (+X-Github-Delivery+, not +X-GitHub-Delivery+). So a vendor's published spelling finds
|
|
17
|
+
# the header it names, and so does any other casing of it:
|
|
18
|
+
#
|
|
19
|
+
# event.content.headers["X-GitHub-Delivery"] # GitHub's own published spelling
|
|
20
|
+
# event.content.headers["x-github-delivery"] # the lowercase form
|
|
21
|
+
# event.content.headers["X-Github-Delivery"] # what the wire actually carried
|
|
22
|
+
#
|
|
23
|
+
# Every read that takes a header name folds: +fetch+, +dig+, +values_at+, +fetch_values+,
|
|
24
|
+
# +to_proc+ and +key?+ (with +has_key?+, +include?+ and +member?+). A header that was
|
|
25
|
+
# genuinely not sent is *absent*, never +nil+ — +[]+ and a fallback-less +fetch+ or
|
|
26
|
+
# +fetch_values+ raise +KeyError+ naming the headers that did arrive, while +fetch+ with
|
|
27
|
+
# a fallback, +dig+ and +values_at+ answer for it the way +Hash+ does.
|
|
28
|
+
#
|
|
29
|
+
# The methods that *reshape* the hash rather than read one name — +slice+, +except+,
|
|
30
|
+
# +select+, +transform_values+ — and the mutators are left exactly as +Hash+ defines
|
|
31
|
+
# them: case-sensitive, on the platform's own spelling. This is a read of one request
|
|
32
|
+
# that already happened. Converting away from this type gives up the folding too
|
|
33
|
+
# (+to_h+, <tt>{**headers}</tt>, and anything else that hands back a plain +Hash+);
|
|
34
|
+
# +merge+ and +dup+ keep it.
|
|
35
|
+
#
|
|
36
|
+
# What it does *not* print is the values: +inspect+, +to_s+ and +pp+ render the header
|
|
37
|
+
# names alone, because these are the sender's headers and one of them may be the
|
|
38
|
+
# sender's credential. Every read is unaffected — and so is +to_json+, which still
|
|
39
|
+
# emits the request verbatim. Converting away from this type gives the redaction up
|
|
40
|
+
# exactly as it gives up the folding, and for the same reason: +slice+, +except+,
|
|
41
|
+
# +select+, +transform_values+ and +to_h+ hand back a plain +Hash+, whose +inspect+
|
|
42
|
+
# prints pairs.
|
|
43
|
+
class RequestHeaders < Hash
|
|
44
|
+
# Hash brings its own render, and a header value is another party's credential. The
|
|
45
|
+
# module puts this class back under the SDK-wide rule; it sits between this class and
|
|
46
|
+
# Hash in the ancestor chain, so all three doors resolve to it.
|
|
47
|
+
include RendersNamesOnly
|
|
48
|
+
|
|
49
|
+
# Hash's own exact-match membership, kept under a private name before the case-folding
|
|
50
|
+
# +key?+ below takes the name: a caller who wrote the wire's own spelling is answered
|
|
51
|
+
# without a scan, and it is the tiebreak if a derived copy holds two casings of one
|
|
52
|
+
# name.
|
|
53
|
+
alias_method :exact_key?, :key?
|
|
54
|
+
private :exact_key?
|
|
55
|
+
|
|
56
|
+
NO_FALLBACK = Object.new.freeze
|
|
57
|
+
private_constant :NO_FALLBACK
|
|
58
|
+
|
|
59
|
+
# +client:+ is unused — headers carry no verbs — but every wrapped model is built with
|
|
60
|
+
# it (see ApiObject.attribute's +wrap:+), so it is accepted.
|
|
61
|
+
def initialize(data, client: nil)
|
|
62
|
+
super()
|
|
63
|
+
update(data)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The header's value, matched case-insensitively. Raises +KeyError+ when no casing of
|
|
67
|
+
# the name was delivered.
|
|
68
|
+
def [](name)
|
|
69
|
+
wire = wire_name(name)
|
|
70
|
+
wire.nil? ? raise_missing(name) : super(wire)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Hash-like: the value, or the fallback — a default argument, or a block, which is
|
|
74
|
+
# handed the name as written — when the header was not delivered. With no fallback it
|
|
75
|
+
# behaves like +[]+.
|
|
76
|
+
def fetch(name, fallback = NO_FALLBACK, &block)
|
|
77
|
+
wire = wire_name(name)
|
|
78
|
+
return super(wire) if wire
|
|
79
|
+
return yield(name) if block
|
|
80
|
+
return fallback unless fallback.equal?(NO_FALLBACK)
|
|
81
|
+
|
|
82
|
+
raise_missing(name)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Did this request carry the header, under any casing?
|
|
86
|
+
def key?(name)
|
|
87
|
+
!wire_name(name).nil?
|
|
88
|
+
end
|
|
89
|
+
alias_method :has_key?, :key?
|
|
90
|
+
alias_method :include?, :key?
|
|
91
|
+
alias_method :member?, :key?
|
|
92
|
+
|
|
93
|
+
# Ruby's nil-returning read, case folded: the value, or +nil+ for a header that was
|
|
94
|
+
# not delivered.
|
|
95
|
+
def dig(name, *rest)
|
|
96
|
+
wire = wire_name(name)
|
|
97
|
+
wire.nil? ? nil : super(wire, *rest)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Several headers at once, folded the same way — +nil+ for one that was not delivered,
|
|
101
|
+
# as +Hash+ answers for a key it has no value for.
|
|
102
|
+
def values_at(*names)
|
|
103
|
+
names.map { |name| dig(name) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# As +values_at+, but a header that was not delivered raises, exactly as +fetch+ does.
|
|
107
|
+
def fetch_values(*names, &block)
|
|
108
|
+
names.map { |name| block ? fetch(name, &block) : fetch(name) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# +&headers+ — a header name to its value, folded, +nil+ for one not delivered.
|
|
112
|
+
def to_proc
|
|
113
|
+
->(name) { dig(name) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# The header *names* this request carried, and none of their values.
|
|
119
|
+
#
|
|
120
|
+
# +Hash+'s own +inspect+ prints every pair, and these headers are the *sender's*,
|
|
121
|
+
# stored verbatim by the platform: a sender that authenticates its POST to an ingest
|
|
122
|
+
# URL puts its credential in one of them, and a contact-page visitor's browser may
|
|
123
|
+
# carry its own cookies or tokens. So +logger.debug(event.content.headers)+ or
|
|
124
|
+
# +logger.debug(message.headers)+ would write another party's secret into our logs.
|
|
125
|
+
#
|
|
126
|
+
# Only the human-facing render changes. Every *read* is untouched and still
|
|
127
|
+
# wire-exact: lookups, +fetch+, +each+, +keys+, +to_h+, +to_json+, +==+.
|
|
128
|
+
#
|
|
129
|
+
# Names are sorted by their string form — a derived copy can hold a key that never
|
|
130
|
+
# came off the wire, and a mixed-type +keys+ has no natural order otherwise.
|
|
131
|
+
#
|
|
132
|
+
# +nil+ for a request the platform recorded with no headers at all, so it renders as
|
|
133
|
+
# the bare class. That is asked of the hash, not of the joined string: a request
|
|
134
|
+
# carrying a single header whose *name* is the empty string joins to "" and must stay
|
|
135
|
+
# distinguishable from one carrying nothing.
|
|
136
|
+
def render_body
|
|
137
|
+
return nil if empty?
|
|
138
|
+
|
|
139
|
+
keys.sort_by(&:to_s).join(", ")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The wire's own spelling of +name+, or nil if no casing of it was delivered. Anything
|
|
143
|
+
# but a String is not a header name, so it is never matched rather than being coerced
|
|
144
|
+
# into one: +headers[:host]+ finds nothing, and so misses like any other absent name.
|
|
145
|
+
# Keys are type-checked too — +merge+ hands back one of these, so a caller's derived
|
|
146
|
+
# copy can hold a key that never came off the wire.
|
|
147
|
+
def wire_name(name)
|
|
148
|
+
return nil unless name.is_a?(String)
|
|
149
|
+
return name if exact_key?(name)
|
|
150
|
+
|
|
151
|
+
each_key { |wire| return wire if wire.is_a?(String) && wire.casecmp?(name) }
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def raise_missing(name)
|
|
156
|
+
raise KeyError.new(
|
|
157
|
+
"No #{name.inspect} header on this request. Header names are matched " \
|
|
158
|
+
"case-insensitively, so no casing of it was sent either. " \
|
|
159
|
+
"Headers present: #{keys.sort_by(&:to_s).inspect}",
|
|
160
|
+
receiver: self, key: name
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BaseCradle
|
|
4
|
+
# Print what an object *is*, never what it holds.
|
|
5
|
+
#
|
|
6
|
+
# Every renderable object in this SDK follows one rule — a render shows field names and
|
|
7
|
+
# never field values — and the rule is what keeps secrets out of logs, REPL transcripts,
|
|
8
|
+
# exception messages and +p+ calls. It is load-bearing in three separate places:
|
|
9
|
+
# +Client+ holds a live +bc_uat_+ token; a +WebhookEventHeaders+ holds whatever an
|
|
10
|
+
# inbound sender put in its headers, which is another party's credential; and an
|
|
11
|
+
# +ApiObject+ holds a record's values, including an endpoint's +ingest_url+.
|
|
12
|
+
#
|
|
13
|
+
# The rule used to exist as three hand-rolled copies of the same three methods, with no
|
|
14
|
+
# fourth class ever being asked the question. That is exactly how the leak in 0.10.2
|
|
15
|
+
# arrived: +WebhookEventHeaders+ was added as a +Hash+ descendant, +Hash+ brought its
|
|
16
|
+
# own render, and every value printed with the suite green. Here it is one module, so a
|
|
17
|
+
# class added later either includes it or fails the reflective guard in
|
|
18
|
+
# +test/basecradle/rendering_test.rb+.
|
|
19
|
+
#
|
|
20
|
+
# Three doors, because a render arrives through three:
|
|
21
|
+
#
|
|
22
|
+
# - +inspect+ — +p+, a REPL echo, an exception's backtrace context, a nested +inspect+.
|
|
23
|
+
# - +to_s+ — string interpolation and +puts+, which is the door people reach for without
|
|
24
|
+
# thinking about it. +Hash+ aliases +to_s+ to its *own* +inspect+ as one shared method
|
|
25
|
+
# entry, so overriding +inspect+ alone leaves <tt>"#{headers}"</tt> printing values.
|
|
26
|
+
# - +pretty_print+ — +pp+. +pp+ uses an object's own +inspect+ when it defines one, which
|
|
27
|
+
# is why an +ApiObject+ was already safe here and only a +Hash+ descendant was not;
|
|
28
|
+
# delegating makes that a property of the module rather than a coincidence of which
|
|
29
|
+
# superclass a class happens to have.
|
|
30
|
+
#
|
|
31
|
+
# +to_s+ is late-bound to +inspect+ rather than written <tt>alias to_s inspect</tt>: an
|
|
32
|
+
# alias copies the method body at alias time, so a subclass that redacts more in
|
|
33
|
+
# +inspect+ would be bypassed by interpolation.
|
|
34
|
+
module RendersNamesOnly
|
|
35
|
+
def inspect
|
|
36
|
+
body = render_body
|
|
37
|
+
body.nil? ? "#<#{self.class}>" : "#<#{self.class} #{body}>"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_s
|
|
41
|
+
inspect
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def pretty_print(printer)
|
|
45
|
+
printer.text(inspect)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# What goes inside the angle brackets after the class name — names, never values.
|
|
51
|
+
# Includers supply it.
|
|
52
|
+
#
|
|
53
|
+
# +nil+ means "there is nothing to show" and renders the bare <tt>#<Class></tt>;
|
|
54
|
+
# anything else is rendered after a space, the empty string included. That
|
|
55
|
+
# distinction is not pedantry: a webhook delivery carrying one header whose *name* is
|
|
56
|
+
# the empty string is not the same delivery as one carrying no headers, and folding
|
|
57
|
+
# the two would report a header that arrived as a header that did not. So emptiness
|
|
58
|
+
# is the includer's question about its own contents, never this module's question
|
|
59
|
+
# about the string it was handed.
|
|
60
|
+
#
|
|
61
|
+
# The default raises rather than returning something bland, because a silent fallback
|
|
62
|
+
# would render an object as its class name alone and look deliberate; this SDK would
|
|
63
|
+
# rather fail in the test that includes the module.
|
|
64
|
+
def render_body
|
|
65
|
+
raise NotImplementedError,
|
|
66
|
+
"#{self.class} includes BaseCradle::RendersNamesOnly but defines no " \
|
|
67
|
+
"#render_body, so there is nothing for #inspect to print. Supply one " \
|
|
68
|
+
"returning field names — never field values."
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/basecradle/version.rb
CHANGED
data/lib/basecradle/webhooks.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "api_object"
|
|
4
|
+
require_relative "headers"
|
|
4
5
|
require_relative "items"
|
|
5
6
|
require_relative "serialization"
|
|
6
7
|
require_relative "user"
|
|
@@ -73,169 +74,9 @@ module BaseCradle
|
|
|
73
74
|
end
|
|
74
75
|
end
|
|
75
76
|
|
|
76
|
-
# One delivery's request headers
|
|
77
|
-
#
|
|
78
|
-
|
|
79
|
-
# +Content-Length+ included — so iterating, +keys+, +to_h+, +to_json+ and +==+ all read
|
|
80
|
-
# the platform's own spelling and nothing is renamed. Only *lookup* folds case, because
|
|
81
|
-
# header names are case-insensitive by RFC and the platform does not preserve the
|
|
82
|
-
# sender's casing: it stores names canonicalized to Title-Case per segment
|
|
83
|
-
# (+X-Github-Delivery+, not +X-GitHub-Delivery+). So a vendor's published spelling finds
|
|
84
|
-
# the header it names, and so does any other casing of it:
|
|
85
|
-
#
|
|
86
|
-
# event.content.headers["X-GitHub-Delivery"] # GitHub's own published spelling
|
|
87
|
-
# event.content.headers["x-github-delivery"] # the lowercase form
|
|
88
|
-
# event.content.headers["X-Github-Delivery"] # what the wire actually carried
|
|
89
|
-
#
|
|
90
|
-
# Every read that takes a header name folds: +fetch+, +dig+, +values_at+, +fetch_values+,
|
|
91
|
-
# +to_proc+ and +key?+ (with +has_key?+, +include?+ and +member?+). A header that was
|
|
92
|
-
# genuinely not delivered is *absent*, never +nil+ — +[]+ and a fallback-less +fetch+ or
|
|
93
|
-
# +fetch_values+ raise +KeyError+ naming the headers that did arrive, while +fetch+ with
|
|
94
|
-
# a fallback, +dig+ and +values_at+ answer for it the way +Hash+ does.
|
|
95
|
-
#
|
|
96
|
-
# The methods that *reshape* the hash rather than read one name — +slice+, +except+,
|
|
97
|
-
# +select+, +transform_values+ — and the mutators are left exactly as +Hash+ defines
|
|
98
|
-
# them: case-sensitive, on the platform's own spelling. This is a read of one delivery
|
|
99
|
-
# that already happened. Converting away from this type gives up the folding too
|
|
100
|
-
# (+to_h+, <tt>{**headers}</tt>, and anything else that hands back a plain +Hash+);
|
|
101
|
-
# +merge+ and +dup+ keep it.
|
|
102
|
-
#
|
|
103
|
-
# What it does *not* print is the values: +inspect+, +to_s+ and +pp+ render the header
|
|
104
|
-
# names alone, because these are the sender's headers and one of them may be the
|
|
105
|
-
# sender's credential. Every read is unaffected — and so is +to_json+, which still
|
|
106
|
-
# emits the delivery verbatim. Converting away from this type gives the redaction up
|
|
107
|
-
# exactly as it gives up the folding, and for the same reason: +slice+, +except+,
|
|
108
|
-
# +select+, +transform_values+ and +to_h+ hand back a plain +Hash+, whose +inspect+
|
|
109
|
-
# prints pairs.
|
|
110
|
-
class WebhookEventHeaders < Hash
|
|
111
|
-
# Hash's own exact-match membership, kept under a private name before the case-folding
|
|
112
|
-
# +key?+ below takes the name: a caller who wrote the wire's own spelling is answered
|
|
113
|
-
# without a scan, and it is the tiebreak if a derived copy holds two casings of one
|
|
114
|
-
# name.
|
|
115
|
-
alias_method :exact_key?, :key?
|
|
116
|
-
private :exact_key?
|
|
117
|
-
|
|
118
|
-
NO_FALLBACK = Object.new.freeze
|
|
119
|
-
private_constant :NO_FALLBACK
|
|
120
|
-
|
|
121
|
-
# +client:+ is unused — headers carry no verbs — but every wrapped model is built with
|
|
122
|
-
# it (see ApiObject.attribute's +wrap:+), so it is accepted.
|
|
123
|
-
def initialize(data, client: nil)
|
|
124
|
-
super()
|
|
125
|
-
update(data)
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
# The header's value, matched case-insensitively. Raises +KeyError+ when no casing of
|
|
129
|
-
# the name was delivered.
|
|
130
|
-
def [](name)
|
|
131
|
-
wire = wire_name(name)
|
|
132
|
-
wire.nil? ? raise_missing(name) : super(wire)
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
# Hash-like: the value, or the fallback — a default argument, or a block, which is
|
|
136
|
-
# handed the name as written — when the header was not delivered. With no fallback it
|
|
137
|
-
# behaves like +[]+.
|
|
138
|
-
def fetch(name, fallback = NO_FALLBACK, &block)
|
|
139
|
-
wire = wire_name(name)
|
|
140
|
-
return super(wire) if wire
|
|
141
|
-
return yield(name) if block
|
|
142
|
-
return fallback unless fallback.equal?(NO_FALLBACK)
|
|
143
|
-
|
|
144
|
-
raise_missing(name)
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
# Did this delivery carry the header, under any casing?
|
|
148
|
-
def key?(name)
|
|
149
|
-
!wire_name(name).nil?
|
|
150
|
-
end
|
|
151
|
-
alias_method :has_key?, :key?
|
|
152
|
-
alias_method :include?, :key?
|
|
153
|
-
alias_method :member?, :key?
|
|
154
|
-
|
|
155
|
-
# Ruby's nil-returning read, case folded: the value, or +nil+ for a header that was
|
|
156
|
-
# not delivered.
|
|
157
|
-
def dig(name, *rest)
|
|
158
|
-
wire = wire_name(name)
|
|
159
|
-
wire.nil? ? nil : super(wire, *rest)
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
# Several headers at once, folded the same way — +nil+ for one that was not delivered,
|
|
163
|
-
# as +Hash+ answers for a key it has no value for.
|
|
164
|
-
def values_at(*names)
|
|
165
|
-
names.map { |name| dig(name) }
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
# As +values_at+, but a header that was not delivered raises, exactly as +fetch+ does.
|
|
169
|
-
def fetch_values(*names, &block)
|
|
170
|
-
names.map { |name| block ? fetch(name, &block) : fetch(name) }
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
# +&headers+ — a header name to its value, folded, +nil+ for one not delivered.
|
|
174
|
-
def to_proc
|
|
175
|
-
->(name) { dig(name) }
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
# The header *names* this delivery carried, and none of their values.
|
|
179
|
-
#
|
|
180
|
-
# +Hash+'s own +inspect+ prints every pair, and these headers are the *sender's*,
|
|
181
|
-
# stored verbatim by the platform: a sender that authenticates its POST to an ingest
|
|
182
|
-
# URL puts its credential in one of them, so +logger.debug(event.content.headers)+
|
|
183
|
-
# would write another party's secret into our logs. Every other renderable object in
|
|
184
|
-
# this SDK is already safe by one rule — +ApiObject#inspect+ prints field names, never
|
|
185
|
-
# values — and this is the one class that sat outside it, because +Hash+ brought its
|
|
186
|
-
# own render.
|
|
187
|
-
#
|
|
188
|
-
# Only the human-facing render changes. Every *read* is untouched and still
|
|
189
|
-
# wire-exact: lookups, +fetch+, +each+, +keys+, +to_h+, +to_json+, +==+.
|
|
190
|
-
#
|
|
191
|
-
# Names are sorted, as +ApiObject#inspect+ sorts its fields and as the +KeyError+ a
|
|
192
|
-
# missing header raises already lists the ones that did arrive.
|
|
193
|
-
def inspect
|
|
194
|
-
return "#<#{self.class}>" if empty?
|
|
195
|
-
|
|
196
|
-
"#<#{self.class} #{keys.sort_by(&:to_s).join(', ')}>"
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
# +Hash+ aliases +to_s+ to its *own* +inspect+ — one shared method entry — so
|
|
200
|
-
# overriding +inspect+ alone would leave <tt>"#{headers}"</tt> and +puts headers+
|
|
201
|
-
# printing every value. Late-bound rather than +alias to_s inspect+, so a subclass
|
|
202
|
-
# that redacts more is not bypassed by interpolation (as +Client#to_s+ is).
|
|
203
|
-
def to_s
|
|
204
|
-
inspect
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
# +pp+ reaches for +pretty_print+, and +Hash+ brings one that prints the pairs — the
|
|
208
|
-
# same leak through the one door +inspect+ does not cover. Delegating puts +pp+ back
|
|
209
|
-
# on the SDK-wide rule: +pp+ uses an object's own +inspect+ when it defines one, which
|
|
210
|
-
# is why every +ApiObject+ is already safe here and only a +Hash+ descendant was not.
|
|
211
|
-
def pretty_print(printer)
|
|
212
|
-
printer.text(inspect)
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
private
|
|
216
|
-
|
|
217
|
-
# The wire's own spelling of +name+, or nil if no casing of it was delivered. Anything
|
|
218
|
-
# but a String is not a header name, so it is never matched rather than being coerced
|
|
219
|
-
# into one: +headers[:host]+ finds nothing, and so misses like any other absent name.
|
|
220
|
-
# Keys are type-checked too — +merge+ hands back one of these, so a caller's derived
|
|
221
|
-
# copy can hold a key that never came off the wire.
|
|
222
|
-
def wire_name(name)
|
|
223
|
-
return nil unless name.is_a?(String)
|
|
224
|
-
return name if exact_key?(name)
|
|
225
|
-
|
|
226
|
-
each_key { |wire| return wire if wire.is_a?(String) && wire.casecmp?(name) }
|
|
227
|
-
nil
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
def raise_missing(name)
|
|
231
|
-
raise KeyError.new(
|
|
232
|
-
"No #{name.inspect} header on this delivery. Header names are matched " \
|
|
233
|
-
"case-insensitively, so no casing of it was delivered either. " \
|
|
234
|
-
"Headers present: #{keys.sort_by(&:to_s).inspect}",
|
|
235
|
-
receiver: self, key: name
|
|
236
|
-
)
|
|
237
|
-
end
|
|
238
|
-
end
|
|
77
|
+
# One delivery's request headers, exactly as the sender sent them — read through
|
|
78
|
+
# RequestHeaders' rules: case-insensitive lookup, a render of names and never values.
|
|
79
|
+
class WebhookEventHeaders < RequestHeaders; end
|
|
239
80
|
|
|
240
81
|
# One inbound delivery: what was sent, and the two facts about the endpoint as it was at
|
|
241
82
|
# the moment of receipt. Everything in the event's embedded endpoint is *current*; these
|
data/lib/basecradle.rb
CHANGED
|
@@ -9,6 +9,8 @@ require_relative "basecradle/pagination"
|
|
|
9
9
|
require_relative "basecradle/items"
|
|
10
10
|
require_relative "basecradle/webhooks"
|
|
11
11
|
require_relative "basecradle/sessions"
|
|
12
|
+
require_relative "basecradle/headers"
|
|
13
|
+
require_relative "basecradle/contact_messages"
|
|
12
14
|
require_relative "basecradle/timeline"
|
|
13
15
|
require_relative "basecradle/timelines"
|
|
14
16
|
require_relative "basecradle/client"
|
|
@@ -18,8 +20,9 @@ require_relative "basecradle/client"
|
|
|
18
20
|
# same API (https://basecradle.com).
|
|
19
21
|
#
|
|
20
22
|
# Start with a client: +BaseCradle::Client.new+ (token from BASECRADLE_TOKEN) or
|
|
21
|
-
# +BaseCradle::Client.login(email_address:, password:)+.
|
|
22
|
-
#
|
|
23
|
-
#
|
|
23
|
+
# +BaseCradle::Client.login(email_address:, password:)+. From there, +bc.me+ answers
|
|
24
|
+
# "who am I?", and every other surface of the BaseCradle API hangs off the client:
|
|
25
|
+
# timelines and their messages, assets, tasks and webhooks, your own sessions, and the
|
|
26
|
+
# users directory with its trust handshake.
|
|
24
27
|
module BaseCradle
|
|
25
28
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: basecradle
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Drawk Kwast
|
|
@@ -22,10 +22,13 @@ files:
|
|
|
22
22
|
- lib/basecradle.rb
|
|
23
23
|
- lib/basecradle/api_object.rb
|
|
24
24
|
- lib/basecradle/client.rb
|
|
25
|
+
- lib/basecradle/contact_messages.rb
|
|
25
26
|
- lib/basecradle/dashboard.rb
|
|
26
27
|
- lib/basecradle/errors.rb
|
|
28
|
+
- lib/basecradle/headers.rb
|
|
27
29
|
- lib/basecradle/items.rb
|
|
28
30
|
- lib/basecradle/pagination.rb
|
|
31
|
+
- lib/basecradle/rendering.rb
|
|
29
32
|
- lib/basecradle/serialization.rb
|
|
30
33
|
- lib/basecradle/sessions.rb
|
|
31
34
|
- lib/basecradle/timeline.rb
|