basecradle 0.10.3 → 0.12.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1fc584abdde2edfd9233ea937bf054ba6fb672708b6c815014d46a1dc4d59c2
4
- data.tar.gz: bc53a3e49ad9eb40fd21e5fb2fe424d054738359a30eb2c137a26c8a5f634d27
3
+ metadata.gz: 38e4a8615dfc599f925f007f022e20323dbb39555dbcc3556a867ccc7d6026dd
4
+ data.tar.gz: 8460d80cc0d400cf5c513ba77f92589a7150f7b10df083c6cb2c123fc60692d3
5
5
  SHA512:
6
- metadata.gz: c603f01c320e465db82dc376ad78cfca4d52c63db107580be7366d2a2c3b79f99e1d5b3f356917bd1526b9f63bb294747cc218a1e7cf71e8fc22caa41ccc4b34
7
- data.tar.gz: 401d5b9b58f5dc726fb3bc457180954b582e077b8b0aa224c0f92513a39c4122bac16ea56dfff4746e44e2b252cc88ab8a6cf12b178d989ee1eed5b2caf1d02a
6
+ metadata.gz: 425c45374d3c0915d392836fdc70e31962f02cec3ab4c6e625954dae83fb0dae58684e768b525860a074c6ce06f3fd92f0e4fe1de10d194a36ccc5eb6c1ad8e5
7
+ data.tar.gz: 9c3b1d147958205aff066f2d17f82841e5ac940279a7cd1c0da1b7be398942f7022dae0ff2f9b1f6a3cc44f407b1242c42da010cb9880a79359a0e2625474ee1
data/CHANGELOG.md CHANGED
@@ -9,6 +9,113 @@ 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.12.0] - 2026-10-17
13
+
14
+ ### Changed
15
+
16
+ - **Breaking: `contact_message.update_status(status)` is now `contact_message.set_status(status)`**
17
+ ([#234](https://github.com/basecradle/basecradle-ruby/issues/234)). Same operation
18
+ (`PATCH /contact_messages/{id}/status`), same argument, same in-place update and
19
+ `self` return; only the name changes. An operation's verb is part of the SDK
20
+ contract: one operation, one verb in every SDK, taken from the API documentation's
21
+ name for it ("Setting a Contact Message's Status"). The Python SDK already says
22
+ `set_status`; this release brings Ruby to the same word (`@origin`, 2026-10-16;
23
+ basecradle#691). No alias is kept — the gem is 0.x and the method shipped one
24
+ release ago.
25
+
26
+ ### Migrating
27
+
28
+ Rename the call: `message.update_status("spam")` becomes `message.set_status("spam")`.
29
+ Calling the old name raises `NoMethodError`.
30
+
31
+ ## [0.11.0] - 2026-10-10
32
+
33
+ ### Added
34
+
35
+ - **Contact messages and notes, the platform's first admin-only surface**
36
+ ([#229](https://github.com/basecradle/basecradle-ruby/issues/229), mirroring
37
+ basecradle#663). All six new operations are covered:
38
+ - `bc.contact_messages`: newest first and auto-paginating, narrowed with
39
+ `.filter(status: "received" | "closed" | "spam")`, and `.get(uuid)` for one record.
40
+ - `contact_message.update_status(status)`: sets the triage verdict and updates the
41
+ object in place.
42
+ - `contact_message.add_note(body:)`: returns the new `BaseCradle::Note`.
43
+ - `bc.notes` (every note, newest first) and `bc.notes.get(uuid)`.
44
+
45
+ A `ContactMessage` is a flat record. It embeds its notes in full, its `user` is `nil`
46
+ for a visitor without an account, and `data` (the vendors' slots) is the platform's
47
+ plain `Hash`, so the vendors are not modeled. A `Note` has the same shape everywhere
48
+ it appears, and its `notable` names its subject's `type` and `uuid`.
49
+ - **`BaseCradle::NotAnAdminError`** (a `ForbiddenError`) for the new `not_an_admin`
50
+ code. Every one of the six operations raises it for a caller who is not an admin.
51
+ - **`bc.me.admin`**, the Dashboard's admin-only sixth section (`contact_messages_url`,
52
+ `notes_url`, `guide_url`). It is present only for an admin. Reading it as anyone else
53
+ raises `MissingFieldError`, like any other withheld field.
54
+ - **`BaseCradle::RequestHeaders`**, the shared class behind a recorded request's
55
+ headers. `WebhookEventHeaders` now subclasses it. Its lookups and render are
56
+ unchanged; the one difference is the wording of the `KeyError` for a missing header,
57
+ which now says "request" where it said "delivery". A contact message's `headers` is the new `ContactMessageHeaders`, which follows
58
+ the same rules: case-insensitive lookup, and a render that shows header names but
59
+ never their values. A visitor's request headers can carry that visitor's own
60
+ credentials, just as a webhook sender's can.
61
+
62
+ ### Fixed
63
+
64
+ - **`binary_payload` maps to `BaseCradle::BinaryPayloadError`.** The API documents
65
+ this webhook-ingest code (415, a body that is not UTF-8 text) beside
66
+ `payload_too_large`, but the SDK's registry never listed it. A 415 therefore surfaced
67
+ as a bare `BaseCradle::Error` instead of its own class.
68
+
69
+ ### Note
70
+
71
+ - **0.10.4 was never published.** Its version and changelog entry merged, but it was
72
+ never tagged, so its changes reach RubyGems for the first time in this release.
73
+
74
+ ## [0.10.4] - 2026-09-30
75
+
76
+ ### Changed
77
+
78
+ - **The names-only render rule is one module, and a reflective test now asks it of every
79
+ class** ([#212](https://github.com/basecradle/basecradle-ruby/issues/212)). Every
80
+ renderable object in this SDK prints its field *names* and never its field values —
81
+ that is what keeps a `bc_uat_` token, an inbound sender's `Authorization` header and an
82
+ endpoint's `ingest_url` out of logs, REPL transcripts and exception messages. Until now
83
+ the rule was three hand-rolled copies of the same three methods with nothing asking the
84
+ question of a fourth class, which is precisely how 0.10.2's leak arrived:
85
+ `WebhookEventHeaders` was added as a `Hash` descendant, `Hash` brought its own render,
86
+ every header value printed, and the suite stayed green. `BaseCradle::RendersNamesOnly`
87
+ now supplies `inspect`, `to_s` and `pretty_print` to `ApiObject`, `Client` and
88
+ `WebhookEventHeaders`, and `test/basecradle/rendering_test.rb` walks the SDK's own
89
+ constants to assert every renderable class gets all three from it. **No existing render
90
+ changed**, `Client`'s included — this is the same output from one place instead of
91
+ three, plus the guard. No leak was found; this closes the shape of the last one.
92
+ - **A record interpolates as its field names instead of a heap address.** `ApiObject` had
93
+ no `to_s` or `pretty_print` of its own, so `"#{message}"` and `puts message` gave
94
+ `#<BaseCradle::Message:0x000074385e23d470>`. They now give
95
+ `#<BaseCradle::Message body, uuid>`, the same string `inspect` and `pp` already gave.
96
+ Nothing was leaking — a heap address is not a value — but two of the three doors on the
97
+ SDK's most common object belonged to `Kernel` rather than to the rule.
98
+ ### Fixed
99
+
100
+ - **A record with mixed-type keys no longer raises from its own render.** `ApiObject`
101
+ sorted its field names with `sort`, which refuses `comparison of Symbol with String
102
+ failed` — reachable because the README documents rebuilding a model from a cached
103
+ record, and a cache layer that symbolizes some keys hands back exactly that. It was one
104
+ raising door before and would have been three after the change above, so it now sorts
105
+ by the names' string form, as `WebhookEventHeaders` always has.
106
+ - **A record with no fields renders as `#<BaseCradle::Message>`**, not
107
+ `#<BaseCradle::Message >` with a dangling space. Reachable whenever the API sends a
108
+ nested content object as `{}`.
109
+
110
+ - **The ActiveSupport harness renders the class 0.10.2 secured.** The child-process probe
111
+ excluded `Hash` descendants, which was a true statement about *serialization* (a
112
+ delivery's headers are a record and serialize) applied to a render question it does not
113
+ answer — so the one class that fix was written for was never rendered under real
114
+ ActiveSupport by the harness that exists for that. There is now a records bucket,
115
+ covered reflectively against every `Hash` descendant the SDK defines, asserting both
116
+ halves: the render stays names-only through all three doors, and the record still
117
+ serializes.
118
+
12
119
  ## [0.10.3] - 2026-09-30
13
120
 
14
121
  ### Security
@@ -565,6 +672,9 @@ the Python SDK's behavior in idiomatic Ruby. Zero runtime dependencies.
565
672
  - **Quality bars** — a README-as-tested-doc harness (every example runs against a mocked
566
673
  API) and a spec drift-guard (CI fails if the live API grows beyond the SDK).
567
674
 
675
+ [0.12.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.12.0
676
+ [0.11.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.11.0
677
+ [0.10.4]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.4
568
678
  [0.10.3]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.3
569
679
  [0.10.2]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.2
570
680
  [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.set_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. `set_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
- `bc.inspect` and `"#{bc}"` are redacted for the same reason — Ruby's default `inspect`
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
- #<BaseCradle::Client base_url="https://basecradle.com" token=[REDACTED]>
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
@@ -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 +set_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
+ # +set_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 +set_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 set_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.set_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
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BaseCradle
4
- VERSION = "0.10.3"
4
+ VERSION = "0.12.0"
5
5
  end
@@ -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: the wire's own spelling, looked up case-insensitively.
77
- #
78
- # A +Hash+ of exactly what the wire carried — one pair per header, +Content-Type+ and
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:)+. The self-discovery +me+
22
- # flow, timelines, messages, sessions, and the trust handshake land in subsequent
23
- # releases, mirroring the BaseCradle API.
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.10.3
4
+ version: 0.12.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