basecradle 0.10.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e662740bb7ecbde3769c3430a269cbe32a0c507a4244626688af1ca13ce3ce4
4
- data.tar.gz: 64820f7409159b2cfd1b063a53c96858302bfc939e4ff2df17b6ffcaf3b494e5
3
+ metadata.gz: 40d7b447c5bbeb18244e440069f09432d808525a96ef2aa1232ae560c3c41194
4
+ data.tar.gz: 12bcc5f74631bd1f48fa8fbe6ea0882283e03cddfcdf68336ed631e7a68e8325
5
5
  SHA512:
6
- metadata.gz: 6c7a007a0c7eef4fe1bb8d4d19fc108c361fc5e5d4a618d5d61600a0cdd55fbb6b97ecc5ea5c07ed8ac6488840b7cc33133c59a6160feab2a04222da73683eeb
7
- data.tar.gz: 7059369ae648787b45a97afe3b12ba721474629078031ac036461732cc7dfc9d13e764b916f48a7e72fa96b248bac4c53c1ee3ed686f2abc98d13ed1c54a6c28
6
+ metadata.gz: 235a1d21044f525ca69c2cffb0909ffc05e8dcd2e63da7d2d5db51f30b34a7eeecf73434a029fc80b08421c1e04dcc61d0461f92e657e27a6b140a19e5f378f0
7
+ data.tar.gz: aa1f71ee1a7efdde1ca609d0768e23efc4c9a2b0f63602e43411752f05ef470b5bfb9cbb0eb4c2c3df38b99b3ca89fedf7de302ff01af5eafd869cd82af8208c
data/CHANGELOG.md CHANGED
@@ -9,6 +9,175 @@ 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
+
100
+ ## [0.10.3] - 2026-09-30
101
+
102
+ ### Security
103
+
104
+ - **`Marshal.dump` and `to_yaml` no longer emit a `Client`'s token**
105
+ ([#205](https://github.com/basecradle/basecradle-ruby/issues/205)). 0.10.1 shut the
106
+ JSON door on a client; `Marshal` and Psych walk instance variables directly, consult no
107
+ `as_json`, and need no ActiveSupport to do it — so both still wrote the raw `bc_uat_`
108
+ credential into their output. They are the worse pair, because they are what puts a
109
+ token *at rest*: ActiveSupport's cache stores marshal what you write, so
110
+ `Rails.cache.write("bc", bc)` landed it in Redis, memcached or a file store; a
111
+ Marshal-backed session landed it in the session; Delayed::Job YAMLs its handler into
112
+ the database. (Queue backends that serialize arguments as **JSON** — ActiveJob and
113
+ Sidekiq among them — go through `to_json`, and so were already closed in 0.10.1.)
114
+ `Marshal.dump(bc)`, `bc.to_yaml`,
115
+ `YAML.dump(conn: bc)` and the `Marshal.load(Marshal.dump(bc))` deep-copy idiom now all
116
+ raise `BaseCradle::NotSerializableError`, the same typed error with the same message
117
+ the JSON door already raised. Long-standing and latent; no token is known to have been
118
+ emitted.
119
+ - **The collection door was the wider one, not the narrower one.** Through JSON, a
120
+ collection never reached the token — ActiveSupport's `Enumerable#as_json` shadows
121
+ `Object#as_json`, so `bc.messages.to_json` serialized fetched records, never the client
122
+ held in an ivar. `Marshal` and Psych have no such shadow, so
123
+ **`Marshal.dump(bc.messages)` reached the credential where `bc.messages.to_json` never
124
+ did** — and a query is the likelier of the two to be handed to a cache. Every lazy
125
+ collection (`bc.timelines`, `bc.messages`, `timeline.tasks`, any `.filter(...)`, the
126
+ `Paginator` behind them) now refuses through all four doors; the refusals are on the
127
+ shared `NotSerializable` mixin, so a resource added later is covered by construction.
128
+ **`.to_a` is only half the remedy for a dumper** — the array it hands back is full of
129
+ models that each hold the client, so `Marshal.dump(bc.messages.to_a)` lands back on the
130
+ same error. Dump `bc.messages.to_a.map(&:to_h)`, which the refusal now says.
131
+ - **Anything holding a client refuses too — models included, and that is the fix.** Both
132
+ walkers recurse, so `Marshal.dump(message)` and `message.to_yaml` reached the client's
133
+ token where `message.to_json` (which serializes the wire record) never did. They now
134
+ raise, naming `BaseCradle::Client` as what was reached. **If you cache or enqueue
135
+ models, cache the record instead** — `message.to_h` is the wire `Hash`, holds no
136
+ client, and marshals exactly as it always did; rebuild with
137
+ `BaseCradle::Message.new(record, client: bc)` when you need the verbs back. Nothing
138
+ else changed: `to_json`, `as_json`, `to_h`, `inspect`, every field reader and
139
+ `dup`/`clone` (which do not go through `Marshal`) are untouched. This matches the
140
+ Python SDK, whose `__reduce__` refusal says the same thing — *“Nothing holding a client
141
+ can be serialized either … copy the record's data instead.”*
142
+
143
+ ### Known limitation
144
+
145
+ - **YAML reaches the `Enumerator` escape that 0.10.1 documented for JSON, on plain
146
+ Ruby.** Every refusal here is on this SDK's own objects; an `Enumerator` you build from
147
+ a collection (`bc.messages.each`, `bc.messages.lazy`) is a plain Ruby object this SDK
148
+ does not own and may not patch. Psych iterates one to dump it — with **no
149
+ ActiveSupport required**, where the `to_json` version is inert without it — so
150
+ `bc.messages.each.to_yaml` fires the page-by-page GET loop and *then* raises
151
+ `NotSerializableError` on the first record, which holds the client: the requests are
152
+ spent and no document comes out. `Marshal` is the exception — Ruby itself refuses to
153
+ dump an `Enumerator` at all. No token is exposed through any of them. Call `.first(n)`
154
+ or `.to_a` before a renderer, and `.map(&:to_h)` too before a dumper.
155
+ - **Four doors is every hook Ruby gives a library**, not every way bytes can be made. A
156
+ serializer that reads instance variables directly rather than through `as_json`,
157
+ `to_json`, `marshal_dump` or `encode_with` is outside what this SDK can intercept.
158
+ Keep a client out of one.
159
+
160
+ ### Migrating
161
+
162
+ Nothing to change unless you `Marshal.dump` or `to_yaml` something that holds a client.
163
+ If you do:
164
+
165
+ - **Caching or enqueuing a model** — `Rails.cache.write(key, message)`,
166
+ `Rails.cache.fetch(key) { bc.messages.get(id) }` — now raises
167
+ `BaseCradle::NotSerializableError` where it used to write your token to the store.
168
+ Cache `message.to_h`, the wire `Hash`, and rebuild with
169
+ `BaseCradle::Message.new(record, client: bc)` if you need the verbs back.
170
+ - **Caching or enqueuing a client or a collection** — serialize nothing; build a client
171
+ where you need one (`BaseCradle::Client.new(token)`), moving the token only through
172
+ whatever you already trust with secrets. For a collection, `.to_a` / `.first(n)` is
173
+ enough for JSON but **not** for a dumper — use `bc.messages.to_a.map(&:to_h)`, since
174
+ the models in that array each hold the client.
175
+ - **Deep-copying with `Marshal.load(Marshal.dump(x))`** — refuses at the dump for the
176
+ same objects. `dup` and `clone` do not go through `Marshal` and are unchanged.
177
+
178
+ If any of these ran in production against a real store, treat the token as disclosed and
179
+ rotate it: `bc.sessions` lists your credentials and `session.revoke` retires one.
180
+
12
181
  ## [0.10.2] - 2026-09-30
13
182
 
14
183
  ### Security
@@ -484,6 +653,9 @@ the Python SDK's behavior in idiomatic Ruby. Zero runtime dependencies.
484
653
  - **Quality bars** — a README-as-tested-doc harness (every example runs against a mocked
485
654
  API) and a spec drift-guard (CI fails if the live API grows beyond the SDK).
486
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
658
+ [0.10.3]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.3
487
659
  [0.10.2]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.2
488
660
  [0.10.1]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.1
489
661
  [0.10.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.0
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
@@ -375,10 +425,15 @@ something Rails renders comes out right too. Three caveats worth knowing:
375
425
  ## What does not serialize: clients and collections
376
426
 
377
427
  Two things in this SDK are *not* records, and serializing either raises
378
- `BaseCradle::NotSerializableError` rather than emitting something:
428
+ `BaseCradle::NotSerializableError` rather than emitting something — and under `Marshal`
429
+ and YAML, so does anything *holding* one, models included (see below). Four doors refuse:
430
+ `to_json`, `as_json`, `Marshal.dump` and `to_yaml` — every hook Ruby gives a library to
431
+ intercept. A serializer that reads instance variables directly instead of through those
432
+ hooks is outside what any library can stop, so keep a client out of one.
379
433
 
380
434
  ```ruby
381
435
  require "basecradle"
436
+ require "yaml"
382
437
 
383
438
  bc = BaseCradle::Client.new
384
439
 
@@ -394,6 +449,18 @@ rescue BaseCradle::NotSerializableError => e
394
449
  puts e.message.include?(".to_a") # => true
395
450
  end
396
451
 
452
+ begin
453
+ Marshal.dump(bc) # and Rails.cache.write("bc", bc), a job argument
454
+ rescue BaseCradle::NotSerializableError => e
455
+ puts e.message.include?("bc_uat_") # => true
456
+ end
457
+
458
+ begin
459
+ bc.messages.to_yaml # YAML walks the ivars, and reaches the client
460
+ rescue BaseCradle::NotSerializableError => e
461
+ puts e.message.include?(".to_a") # => true
462
+ end
463
+
397
464
  puts bc.messages.first(20).to_json # serialize the page you actually asked for
398
465
  ```
399
466
 
@@ -410,18 +477,89 @@ walk — but a client's instance variables are eight collections, so serializing
410
477
  ran those loops too, on its way to the credential.) Call `.to_a` or `.first(n)` yourself
411
478
  and serialize that, so how much you fetch is a visible act in your code.
412
479
 
413
- Both refusals happen before any HTTP — but they are on the SDK's own objects. An
480
+ **`Marshal` and YAML are the same refusal for a different reason.** Both walk instance
481
+ variables directly, so neither needed ActiveSupport to reach the token — and neither is
482
+ shadowed the way `Enumerable#as_json` shadowed the ivar walk, so `Marshal.dump(bc.messages)`
483
+ reached the credential where `bc.messages.to_json` never did. They are also where a leaked
484
+ token stops being a log line and becomes a token *at rest*: ActiveSupport's cache stores
485
+ marshal what you write, so `Rails.cache.write("bc", bc)` landed it in Redis, memcached or
486
+ a file; a Marshal-backed session landed it in the session; Delayed::Job YAMLs its handler
487
+ into the database. `Marshal.load(Marshal.dump(x))`, the deep-copy idiom, refuses at the
488
+ dump. (Queue backends that serialize arguments as **JSON** — ActiveJob and Sidekiq among
489
+ them — go through `to_json`, and so were already closed in 0.10.1.)
490
+
491
+ **Anything *holding* a client refuses too, models included** — both walkers recurse, so
492
+ `Marshal.dump(message)` reaches the client and raises naming `BaseCradle::Client`. That is
493
+ the fix, not a limitation: before, it emitted the token. It is also why **`.to_a` is only
494
+ half the remedy for a dumper**: the array it hands back is full of models that each hold
495
+ the client, so `Marshal.dump(bc.messages.to_a)` lands straight back on the same error.
496
+ Dump the wire records — which is what you wanted in a cache anyway:
497
+
498
+ ```ruby
499
+ require "basecradle"
500
+
501
+ bc = BaseCradle::Client.new
502
+ message = bc.messages.first
503
+
504
+ record = message.to_h # the wire Hash, no client in it
505
+ puts Marshal.load(Marshal.dump(record)) == record # => true
506
+
507
+ page = bc.messages.first(20).map(&:to_h) # a whole page, no client in it
508
+ puts Marshal.load(Marshal.dump(page)) == page # => true
509
+ ```
510
+
511
+ Rebuild a model from a cached record with `BaseCradle::Message.new(record, client: bc)`
512
+ when you need its verbs back.
513
+
514
+ Every refusal happens before any HTTP — but they are on the SDK's own objects. An
414
515
  `Enumerator` you built from one is a plain Ruby object this SDK does not own, so
415
516
  `render json: { recent: bc.messages.lazy }` and `bc.messages.each.to_json` still page the
416
- whole resource. Call `.first(n)` or `.to_a` before handing a query to a renderer.
517
+ whole resource. **YAML reaches that escape on plain Ruby**, where the `to_json` version
518
+ needs ActiveSupport: Psych iterates an `Enumerator` to dump it, so
519
+ `bc.messages.each.to_yaml` fires the page-by-page GET loop and *then* raises
520
+ `NotSerializableError` on the first record (which holds the client) — the requests are
521
+ spent, and no document comes out. (`Marshal` is the exception: Ruby itself refuses to
522
+ dump an `Enumerator` at all.) No token is exposed through any of them. Call `.first(n)`
523
+ or `.to_a` before handing a query to a renderer, and `.map(&:to_h)` too before a dumper.
524
+
525
+ ### Renders show names, never values
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
417
535
 
418
- `bc.inspect` and `"#{bc}"` are redacted for the same reason — Ruby's default `inspect`
419
- dumps every instance variable, which would print the token into every exception message
420
- and REPL transcript that touched a client:
536
+ bc.inspect # => "#<BaseCradle::Client base_url=\"https://basecradle.com\" token=[REDACTED]>"
537
+ "#{bc}" # => "#<BaseCradle::Client base_url=\"https://basecradle.com\" token=[REDACTED]>"
421
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
422
557
  ```
423
- #<BaseCradle::Client base_url="https://basecradle.com" token=[REDACTED]>
424
- ```
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.
425
563
 
426
564
  ## Development
427
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,14 +237,38 @@ module BaseCradle
243
237
 
244
238
  private
245
239
 
246
- # Why +to_json+ / +as_json+ refuse (see BaseCradle::NotSerializable). A client is not
247
- # a record, and the thing it would emit is a live credential.
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
+
250
+ # Why every serializer refuses — +to_json+ / +as_json+, +Marshal.dump+, +to_yaml+
251
+ # (see BaseCradle::NotSerializable). A client is not a record, and the thing it would
252
+ # emit is a live credential.
253
+ #
254
+ # The last sentence is for the caller who never named a client: Marshal and Psych
255
+ # recurse, so dumping a model or a resource reaches the client it holds and lands
256
+ # here. Telling that caller only about clients would be telling them about an object
257
+ # they did not pass — the Python SDK's +__reduce__+ is worded for the same reason
258
+ # ("copying a resource or a record reaches this too — copy the record's data").
259
+ #
260
+ # And it has to name +to_h+, because the first remedy does not hold through those two
261
+ # doors: a model serializes as its record through JSON, but <tt>Marshal.dump(bc.me)</tt>
262
+ # walks to the client again. Advice that fails through the door the reader just used
263
+ # is worse than none.
248
264
  def serialization_refusal
249
265
  "#{self.class} is a connection holding your bc_uat_ token, not a record. " \
250
- "Serializing it writes the raw credential into whatever you were rendering or " \
251
- "logging, and a token in a log is a token to rotate. Serialize the record you " \
252
- "meant instead (bc.me, a timeline, a message), or bc.base_url to name the " \
253
- "connection itself."
266
+ "Serializing it writes the raw credential into whatever you were rendering, " \
267
+ "logging or storing, and a token in a log or a cache is a token to rotate. " \
268
+ "Serialize the record you meant instead (bc.me, a timeline, a message), or " \
269
+ "bc.base_url to name the connection itself. Marshal and YAML reach a client " \
270
+ "through anything holding one, models included, so dump the wire record rather " \
271
+ "than the object: model.to_h holds no client."
254
272
  end
255
273
 
256
274
  # Record what the mint response said about the credential just issued. Private: only