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 +4 -4
- data/CHANGELOG.md +172 -0
- data/README.md +146 -8
- data/lib/basecradle/api_object.rb +24 -4
- data/lib/basecradle/client.rb +41 -23
- data/lib/basecradle/contact_messages.rb +164 -0
- data/lib/basecradle/dashboard.rb +13 -1
- data/lib/basecradle/errors.rb +18 -5
- data/lib/basecradle/headers.rb +164 -0
- data/lib/basecradle/rendering.rb +71 -0
- data/lib/basecradle/serialization.rb +52 -8
- 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,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
|
-
|
|
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.
|
|
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
|
-
|
|
419
|
-
|
|
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
|
-
|
|
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
|
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,14 +237,38 @@ module BaseCradle
|
|
|
243
237
|
|
|
244
238
|
private
|
|
245
239
|
|
|
246
|
-
#
|
|
247
|
-
#
|
|
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
|
|
251
|
-
"logging, and a token in a log is a token to rotate.
|
|
252
|
-
"meant instead (bc.me, a timeline, a message), or
|
|
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
|