basecradle 0.10.0 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +90 -0
- data/README.md +70 -0
- data/lib/basecradle/client.rb +28 -1
- data/lib/basecradle/errors.rb +6 -0
- data/lib/basecradle/items.rb +5 -0
- data/lib/basecradle/pagination.rb +3 -0
- data/lib/basecradle/serialization.rb +74 -0
- data/lib/basecradle/sessions.rb +2 -0
- data/lib/basecradle/timelines.rb +2 -0
- data/lib/basecradle/user.rb +2 -0
- data/lib/basecradle/version.rb +1 -1
- data/lib/basecradle/webhooks.rb +48 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4e662740bb7ecbde3769c3430a269cbe32a0c507a4244626688af1ca13ce3ce4
|
|
4
|
+
data.tar.gz: 64820f7409159b2cfd1b063a53c96858302bfc939e4ff2df17b6ffcaf3b494e5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6c7a007a0c7eef4fe1bb8d4d19fc108c361fc5e5d4a618d5d61600a0cdd55fbb6b97ecc5ea5c07ed8ac6488840b7cc33133c59a6160feab2a04222da73683eeb
|
|
7
|
+
data.tar.gz: 7059369ae648787b45a97afe3b12ba721474629078031ac036461732cc7dfc9d13e764b916f48a7e72fa96b248bac4c53c1ee3ed686f2abc98d13ed1c54a6c28
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,94 @@ section**. The newest heading is always the version `lib/basecradle/version.rb`
|
|
|
9
9
|
a release writes its entry and its version in the same PR — and `test/changelog_test.rb`
|
|
10
10
|
fails CI if the two ever disagree.
|
|
11
11
|
|
|
12
|
+
## [0.10.2] - 2026-09-30
|
|
13
|
+
|
|
14
|
+
### Security
|
|
15
|
+
|
|
16
|
+
- **`WebhookEventHeaders` no longer prints header values when inspected**
|
|
17
|
+
([#206](https://github.com/basecradle/basecradle-ruby/issues/206)). An inbound
|
|
18
|
+
delivery's headers are the *sender's*, stored verbatim by the platform, so one of them
|
|
19
|
+
may be the sender's own credential — a POST authenticated to an ingest URL carries its
|
|
20
|
+
`Authorization` or `X-Api-Key` right there. `WebhookEventHeaders` is a `Hash`
|
|
21
|
+
descendant and inherited `Hash`'s render, which prints every pair, so
|
|
22
|
+
`logger.debug(event.content.headers)` wrote **another party's secret** into your logs.
|
|
23
|
+
`inspect`, `to_s` and `pp` now render the header names alone, sorted, matching the rule
|
|
24
|
+
every other object in this SDK already followed (`ApiObject#inspect` prints field
|
|
25
|
+
names, never values):
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
#<BaseCradle::WebhookEventHeaders Authorization, Content-Type, X-Api-Key>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Only the human-facing render changed.** Every read is untouched and still wire-exact:
|
|
32
|
+
the case-folded lookup, `fetch`, `each`, `keys`, `to_h`, `to_json`, `==`. If you were
|
|
33
|
+
relying on `inspect` to see values, use `to_h` — which is what it was always for. No
|
|
34
|
+
credential of yours was exposed; the exposure was of whoever sent you the webhook, so
|
|
35
|
+
if you have logged inbound deliveries at debug level, treat those senders' headers as
|
|
36
|
+
disclosed.
|
|
37
|
+
- **Two ways a header value still reaches a log, both by design.** `to_json`/`as_json`
|
|
38
|
+
emit the delivery verbatim — that is what a webhook record *is* — so
|
|
39
|
+
`render json: event` and `logger.info(event.to_json)` still write the sender's headers
|
|
40
|
+
in full. And converting away from the type gives up the redaction exactly as it gives up
|
|
41
|
+
the case-folding: `slice`, `except`, `select`, `transform_values` and `to_h` each hand
|
|
42
|
+
back a plain `Hash`, whose `inspect` prints pairs. Only `merge` and `dup` keep the type,
|
|
43
|
+
and so the redaction. Changing what a webhook record *serializes* as is a separate
|
|
44
|
+
decision and is not made here.
|
|
45
|
+
|
|
46
|
+
## [0.10.1] - 2026-09-30
|
|
47
|
+
|
|
48
|
+
### Security
|
|
49
|
+
|
|
50
|
+
- **A `Client` no longer serializes, and never emits its token**
|
|
51
|
+
([#198](https://github.com/basecradle/basecradle-ruby/issues/198)). A client is a
|
|
52
|
+
connection, not a record, and it defined no `as_json`/`to_json` — so with ActiveSupport
|
|
53
|
+
loaded it fell through to `Object#as_json`, which is `instance_values`, walked
|
|
54
|
+
recursively. Its instance variables include the raw `bc_uat_` token, so
|
|
55
|
+
`render json: { conn: bc }` or `logger.info({ conn: bc }.to_json)` wrote the live
|
|
56
|
+
credential into a response body or a log line, and **a token in a log is a token to
|
|
57
|
+
rotate**. `bc.to_json` now raises `BaseCradle::NotSerializableError`, a
|
|
58
|
+
`BaseCradle::Error`, naming what to serialize instead. Long-standing and latent; no
|
|
59
|
+
token is known to have been emitted.
|
|
60
|
+
- **`Client#inspect` and `#to_s` redact the token** — the same exposure through a
|
|
61
|
+
different door. Ruby's default `inspect` dumps every instance variable, so the
|
|
62
|
+
credential printed into every exception message, REPL transcript and `p` call that
|
|
63
|
+
touched a client. Both now read
|
|
64
|
+
`#<BaseCradle::Client base_url="https://basecradle.com" token=[REDACTED]>`, with `to_s`
|
|
65
|
+
the same string because interpolation is the door people reach for without thinking.
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- **A collection resource no longer serializes either.** `bc.timelines`, `bc.messages`,
|
|
70
|
+
`timeline.tasks`, any `.filter(...)`, and the `Paginator` behind them are lazy queries,
|
|
71
|
+
not records. ActiveSupport's `Enumerable#as_json` calls `to_a`, so serializing one ran
|
|
72
|
+
a page-by-page GET loop over the whole resource from **inside a view render** and
|
|
73
|
+
emitted every record it fetched. It did *not* reach the token — `Enumerable#as_json`
|
|
74
|
+
shadows the `instance_values` walk above — but a client's instance variables are eight
|
|
75
|
+
collections, so serializing a client fired those loops on its way to the credential.
|
|
76
|
+
All of them now raise the same `NotSerializableError`, before any HTTP, pointing at
|
|
77
|
+
`.to_a` so how much you fetch is a visible act in your own code.
|
|
78
|
+
Without ActiveSupport these calls were useless rather than dangerous
|
|
79
|
+
(`"#<BaseCradle::MessagesResource:0x…>"`, the heap address 0.10.0 removed for models);
|
|
80
|
+
that is closed too.
|
|
81
|
+
- **The collection resources' `inspect` is unchanged** and still shows Ruby's default
|
|
82
|
+
ivar dump — which renders the client through the redacting `Client#inspect` above, so
|
|
83
|
+
no token reaches it. Pinned by test so it stays that way.
|
|
84
|
+
|
|
85
|
+
### Known limitation
|
|
86
|
+
|
|
87
|
+
The refusals are on the SDK's own objects. An `Enumerator` built from a collection —
|
|
88
|
+
`bc.messages.each`, `bc.messages.lazy` — is a plain Ruby object this SDK does not own,
|
|
89
|
+
and ActiveSupport gives it an `as_json` that calls `to_a`, so
|
|
90
|
+
`render json: { recent: bc.messages.lazy }` still pages the whole resource. No token is
|
|
91
|
+
exposed; call `.first(n)` or `.to_a` before handing a query to a renderer.
|
|
92
|
+
|
|
93
|
+
### Migrating
|
|
94
|
+
|
|
95
|
+
Nothing to change unless you were serializing a `Client` or a collection, which never
|
|
96
|
+
emitted anything useful and, for a client under ActiveSupport, emitted your token. If you
|
|
97
|
+
were: serialize the record you meant (`bc.me`, a timeline, a message), or call `.to_a` /
|
|
98
|
+
`.first(n)` on a collection first.
|
|
99
|
+
|
|
12
100
|
## [0.10.0] - 2026-09-30
|
|
13
101
|
|
|
14
102
|
### Fixed
|
|
@@ -396,6 +484,8 @@ the Python SDK's behavior in idiomatic Ruby. Zero runtime dependencies.
|
|
|
396
484
|
- **Quality bars** — a README-as-tested-doc harness (every example runs against a mocked
|
|
397
485
|
API) and a spec drift-guard (CI fails if the live API grows beyond the SDK).
|
|
398
486
|
|
|
487
|
+
[0.10.2]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.2
|
|
488
|
+
[0.10.1]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.1
|
|
399
489
|
[0.10.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.0
|
|
400
490
|
[0.9.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.9.0
|
|
401
491
|
[0.8.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.8.0
|
data/README.md
CHANGED
|
@@ -220,6 +220,19 @@ fallback-less `fetch` raise `KeyError` naming the headers that did arrive, while
|
|
|
220
220
|
and the methods that reshape it (`slice`, `except`, `select`) work on those stored names,
|
|
221
221
|
case-sensitively.
|
|
222
222
|
|
|
223
|
+
What it does **not** show you is the values. `inspect`, `to_s` and `pp` print the header
|
|
224
|
+
names alone — `#<BaseCradle::WebhookEventHeaders Authorization, Content-Type>` — because
|
|
225
|
+
these headers are the **sender's**, and a sender that authenticates its POST to your
|
|
226
|
+
ingest URL carries its credential in one of them. So `logger.debug(event.content.headers)`
|
|
227
|
+
cannot write another party's secret into your logs. Every read is unaffected; `to_h` is
|
|
228
|
+
how you look at values.
|
|
229
|
+
|
|
230
|
+
Two things do still show them, deliberately. `to_json` emits the delivery verbatim — that
|
|
231
|
+
is what the record is — so `render json: event` and `logger.info(event.to_json)` write the
|
|
232
|
+
headers in full. And converting away from the type gives up the redaction exactly as it
|
|
233
|
+
gives up the case-folding: `slice`, `except`, `select`, `transform_values` and `to_h` hand
|
|
234
|
+
back a plain `Hash`, which prints pairs. `merge` and `dup` keep the type, and keep both.
|
|
235
|
+
|
|
223
236
|
This holds however you reach the record. A timeline item's `content` is typed by the
|
|
224
237
|
item's own `type`, so a `webhook_event` row of `timeline.items` is a `WebhookEventContent`
|
|
225
238
|
just like one from `bc.webhook_events` — `item.content.headers["X-GitHub-Delivery"]` folds
|
|
@@ -359,6 +372,57 @@ something Rails renders comes out right too. Three caveats worth knowing:
|
|
|
359
372
|
pretty-prints. What an unknown option does is your `json` version's business — 3.x
|
|
360
373
|
raises, the 2.x older Rubies ship ignores it.
|
|
361
374
|
|
|
375
|
+
## What does not serialize: clients and collections
|
|
376
|
+
|
|
377
|
+
Two things in this SDK are *not* records, and serializing either raises
|
|
378
|
+
`BaseCradle::NotSerializableError` rather than emitting something:
|
|
379
|
+
|
|
380
|
+
```ruby
|
|
381
|
+
require "basecradle"
|
|
382
|
+
|
|
383
|
+
bc = BaseCradle::Client.new
|
|
384
|
+
|
|
385
|
+
begin
|
|
386
|
+
bc.to_json # and render json: { conn: bc }
|
|
387
|
+
rescue BaseCradle::NotSerializableError => e
|
|
388
|
+
puts e.message.include?("bc_uat_") # => true
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
begin
|
|
392
|
+
bc.messages.to_json # a lazy query, not a record
|
|
393
|
+
rescue BaseCradle::NotSerializableError => e
|
|
394
|
+
puts e.message.include?(".to_a") # => true
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
puts bc.messages.first(20).to_json # serialize the page you actually asked for
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
A **client** holds your `bc_uat_` token, and under ActiveSupport `Object#as_json` is
|
|
401
|
+
`instance_values` — walked recursively. So `render json: { conn: bc }` or
|
|
402
|
+
`logger.info({ conn: bc }.to_json)` used to write the raw credential into a response body
|
|
403
|
+
or a log line, and a token in a log is a token to rotate.
|
|
404
|
+
|
|
405
|
+
A **collection** (`bc.timelines`, `bc.messages`, `timeline.tasks`, any `.filter(...)`) is
|
|
406
|
+
a lazy query. ActiveSupport's `Enumerable#as_json` calls `to_a`, so serializing one ran a
|
|
407
|
+
page-by-page GET loop over the whole resource from inside the render and emitted every
|
|
408
|
+
record it fetched. (It did *not* reach the token — `Enumerable#as_json` shadows the ivar
|
|
409
|
+
walk — but a client's instance variables are eight collections, so serializing a *client*
|
|
410
|
+
ran those loops too, on its way to the credential.) Call `.to_a` or `.first(n)` yourself
|
|
411
|
+
and serialize that, so how much you fetch is a visible act in your code.
|
|
412
|
+
|
|
413
|
+
Both refusals happen before any HTTP — but they are on the SDK's own objects. An
|
|
414
|
+
`Enumerator` you built from one is a plain Ruby object this SDK does not own, so
|
|
415
|
+
`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.
|
|
417
|
+
|
|
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:
|
|
421
|
+
|
|
422
|
+
```
|
|
423
|
+
#<BaseCradle::Client base_url="https://basecradle.com" token=[REDACTED]>
|
|
424
|
+
```
|
|
425
|
+
|
|
362
426
|
## Development
|
|
363
427
|
|
|
364
428
|
```bash
|
|
@@ -375,6 +439,12 @@ CI additionally lints the workflow files with [actionlint](https://github.com/rh
|
|
|
375
439
|
both and run `actionlint` from the repo root — CI pins actionlint by version and SHA256 in
|
|
376
440
|
`.github/workflows/ci.yml`; match that version to see what CI sees.
|
|
377
441
|
|
|
442
|
+
Bare `actionlint` covers slightly more than CI blocks on. It lints everything under
|
|
443
|
+
`.github/workflows`, including `needs-human-alert.yml` and `dependabot-auto-merge.yml`,
|
|
444
|
+
which are shared verbatim from the BaseCradle core repo and are not this repo's to edit;
|
|
445
|
+
CI lints those two advisory-only, so a finding in them is reported upstream rather than
|
|
446
|
+
failing the build. A finding in any other workflow does fail it.
|
|
447
|
+
|
|
378
448
|
## Contributing
|
|
379
449
|
|
|
380
450
|
Human and AI contributors work under identical rules here: branch → PR → green CI → merge. See [`CLAUDE.md`](CLAUDE.md) for the project conventions and the issues for the roadmap.
|
data/lib/basecradle/client.rb
CHANGED
|
@@ -7,6 +7,7 @@ require "uri"
|
|
|
7
7
|
require_relative "dashboard"
|
|
8
8
|
require_relative "errors"
|
|
9
9
|
require_relative "items"
|
|
10
|
+
require_relative "serialization"
|
|
10
11
|
require_relative "sessions"
|
|
11
12
|
require_relative "timelines"
|
|
12
13
|
require_relative "user"
|
|
@@ -23,6 +24,10 @@ module BaseCradle
|
|
|
23
24
|
# Every resource is built on +#request+, which is also the escape hatch for API
|
|
24
25
|
# endpoints added before the SDK wraps them (the API is additive-only).
|
|
25
26
|
class Client
|
|
27
|
+
# 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.
|
|
29
|
+
include NotSerializable
|
|
30
|
+
|
|
26
31
|
DEFAULT_BASE_URL = "https://basecradle.com"
|
|
27
32
|
DEFAULT_TIMEOUT = 30
|
|
28
33
|
|
|
@@ -192,8 +197,20 @@ module BaseCradle
|
|
|
192
197
|
handle(response)
|
|
193
198
|
end
|
|
194
199
|
|
|
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.
|
|
195
206
|
def inspect
|
|
196
|
-
"#<#{self.class} base_url=#{@base_url.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
|
|
197
214
|
end
|
|
198
215
|
|
|
199
216
|
# The shared low-level send: returns the Net::HTTPResponse or raises APIConnectionError.
|
|
@@ -226,6 +243,16 @@ module BaseCradle
|
|
|
226
243
|
|
|
227
244
|
private
|
|
228
245
|
|
|
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.
|
|
248
|
+
def serialization_refusal
|
|
249
|
+
"#{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."
|
|
254
|
+
end
|
|
255
|
+
|
|
229
256
|
# Record what the mint response said about the credential just issued. Private: only
|
|
230
257
|
# +.login+ calls it, on a client it has just built.
|
|
231
258
|
def minted(body)
|
data/lib/basecradle/errors.rb
CHANGED
|
@@ -30,6 +30,12 @@ module BaseCradle
|
|
|
30
30
|
# this response form). The SDK raises rather than return an ambiguous nil.
|
|
31
31
|
class MissingFieldError < Error; end
|
|
32
32
|
|
|
33
|
+
# A Client or a collection resource was serialized (+to_json+ / +as_json+). Neither is
|
|
34
|
+
# a record: a Client holds your bearer token, and a collection is a lazy query whose
|
|
35
|
+
# serialization would page the whole API from inside your renderer. The SDK refuses
|
|
36
|
+
# loudly rather than emit a credential or a heap address — see BaseCradle::NotSerializable.
|
|
37
|
+
class NotSerializableError < Error; end
|
|
38
|
+
|
|
33
39
|
# The request never got an API response (DNS failure, refused connection, timeout).
|
|
34
40
|
# The underlying exception is preserved as +cause+.
|
|
35
41
|
class APIConnectionError < Error; end
|
data/lib/basecradle/items.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "time"
|
|
|
5
5
|
|
|
6
6
|
require_relative "api_object"
|
|
7
7
|
require_relative "pagination"
|
|
8
|
+
require_relative "serialization"
|
|
8
9
|
require_relative "user"
|
|
9
10
|
|
|
10
11
|
module BaseCradle
|
|
@@ -93,6 +94,7 @@ module BaseCradle
|
|
|
93
94
|
# PLURAL / SINGULAR / MODEL.
|
|
94
95
|
class ItemsResource
|
|
95
96
|
include Enumerable
|
|
97
|
+
include NotSerializableCollection
|
|
96
98
|
|
|
97
99
|
def initialize(client, filters: {})
|
|
98
100
|
@client = client
|
|
@@ -163,6 +165,7 @@ module BaseCradle
|
|
|
163
165
|
# One timeline's messages: create here, or iterate (newest first).
|
|
164
166
|
class TimelineMessages
|
|
165
167
|
include Enumerable
|
|
168
|
+
include NotSerializableCollection
|
|
166
169
|
|
|
167
170
|
def initialize(client, timeline_uuid)
|
|
168
171
|
@client = client
|
|
@@ -190,6 +193,7 @@ module BaseCradle
|
|
|
190
193
|
# One timeline's assets: upload here (multipart), or iterate (newest first).
|
|
191
194
|
class TimelineAssets
|
|
192
195
|
include Enumerable
|
|
196
|
+
include NotSerializableCollection
|
|
193
197
|
|
|
194
198
|
def initialize(client, timeline_uuid)
|
|
195
199
|
@client = client
|
|
@@ -234,6 +238,7 @@ module BaseCradle
|
|
|
234
238
|
# One timeline's tasks: create here, or iterate (newest first).
|
|
235
239
|
class TimelineTasks
|
|
236
240
|
include Enumerable
|
|
241
|
+
include NotSerializableCollection
|
|
237
242
|
|
|
238
243
|
def initialize(client, timeline_uuid)
|
|
239
244
|
@client = client
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "serialization"
|
|
4
|
+
|
|
3
5
|
module BaseCradle
|
|
4
6
|
# The shared cursor-pagination engine. Every list endpoint paginates the same way:
|
|
5
7
|
# newest first, up to 50 per page, +next_cursor+ in the response passed back as
|
|
@@ -10,6 +12,7 @@ module BaseCradle
|
|
|
10
12
|
# and cursors never appear in calling code.
|
|
11
13
|
class Paginator
|
|
12
14
|
include Enumerable
|
|
15
|
+
include NotSerializableCollection
|
|
13
16
|
|
|
14
17
|
def initialize(client, path, envelope_key:, model:, params: nil)
|
|
15
18
|
@client = client
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module BaseCradle
|
|
6
|
+
# Refuse to serialize an object that is a connection or a live query rather than a
|
|
7
|
+
# record, and say what to serialize instead.
|
|
8
|
+
#
|
|
9
|
+
# With ActiveSupport loaded, +Object#as_json+ is +instance_values+ — every instance
|
|
10
|
+
# variable, walked recursively. A Client's ivars include its raw +bc_uat_+ token, so
|
|
11
|
+
# <tt>render json: { conn: bc }</tt> or <tt>logger.info({ conn: bc }.to_json)</tt> put
|
|
12
|
+
# the credential in a response body or a log line, and **a token in a log is a token to
|
|
13
|
+
# rotate**. That same walk reaches the client's eight collection ivars, and
|
|
14
|
+
# ActiveSupport's +Enumerable#as_json+ calls +to_a+ — so serializing a client also fired
|
|
15
|
+
# a live, unbounded, page-by-page GET loop before it got to the token, and serializing a
|
|
16
|
+
# collection on its own fired one from inside the render for no stated reason.
|
|
17
|
+
#
|
|
18
|
+
# Without ActiveSupport the same calls were merely useless: +"#<BaseCradle::Client:0x…>"+,
|
|
19
|
+
# a heap address that differs every run.
|
|
20
|
+
#
|
|
21
|
+
# Includers supply +serialization_refusal+; the default below is the honest fallback
|
|
22
|
+
# for one that does not, so the failure is still a rescuable BaseCradle::Error rather
|
|
23
|
+
# than a NoMethodError out of a renderer.
|
|
24
|
+
module NotSerializable
|
|
25
|
+
# ActiveSupport's serialization hook — what reaches this object when it is nested in
|
|
26
|
+
# something Rails renders (<tt>render json: { conn: bc }</tt>).
|
|
27
|
+
def as_json(*)
|
|
28
|
+
raise NotSerializableError, serialization_refusal
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# +JSON.generate+, +Hash#to_json+, +Array#to_json+ and a bare
|
|
32
|
+
# <tt>render json: bc</tt> all arrive here.
|
|
33
|
+
def to_json(*)
|
|
34
|
+
raise NotSerializableError, serialization_refusal
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def serialization_refusal
|
|
40
|
+
"#{self.class} is not a record and cannot be serialized."
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# What every lazy collection resource mixes in — the auto-paginating resources
|
|
45
|
+
# (+bc.timelines+, +bc.messages+, +timeline.tasks+, any +.filter(...)+) and the
|
|
46
|
+
# Paginator behind them.
|
|
47
|
+
#
|
|
48
|
+
# It includes +Enumerable+ itself, and that is load-bearing rather than tidy:
|
|
49
|
+
# ActiveSupport defines +as_json+ and +to_json+ on +Enumerable+, so the refusal only
|
|
50
|
+
# takes effect while it sits *ahead* of Enumerable in the ancestor chain. Pulling
|
|
51
|
+
# Enumerable in here makes that true whichever order an including class writes its
|
|
52
|
+
# +include+ lines — a module already in the chain is not moved by a later include — so
|
|
53
|
+
# the guarantee is structural instead of a rule in a comment that a future resource can
|
|
54
|
+
# forget. The classes still write +include Enumerable+ explicitly, because being
|
|
55
|
+
# Enumerable is part of what they are.
|
|
56
|
+
module NotSerializableCollection
|
|
57
|
+
include Enumerable
|
|
58
|
+
include NotSerializable
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Deliberately does *not* claim a token leak. Measured on this tree: ActiveSupport's
|
|
63
|
+
# +Enumerable#as_json+ shadows +Object#as_json+, so a collection serialized as its
|
|
64
|
+
# fetched records, never as its ivars — the client it holds was never walked. The
|
|
65
|
+
# harm is the unbounded fetch and the records that came out with it.
|
|
66
|
+
def serialization_refusal
|
|
67
|
+
"#{self.class} is a lazy, auto-paginating query, not a record, so there is no one " \
|
|
68
|
+
"record to serialize. Serializing it runs a page-by-page GET loop over the whole " \
|
|
69
|
+
"resource from inside your renderer, and emits every record it fetched. Call " \
|
|
70
|
+
".to_a (or .first(n), or .filter(...).to_a) and serialize that — then how much you " \
|
|
71
|
+
"fetch is a visible act in your own code."
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/basecradle/sessions.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "api_object"
|
|
4
4
|
require_relative "pagination"
|
|
5
|
+
require_relative "serialization"
|
|
5
6
|
|
|
6
7
|
module BaseCradle
|
|
7
8
|
# One credential you hold — a web sign-in or a bc_uat_ API token.
|
|
@@ -39,6 +40,7 @@ module BaseCradle
|
|
|
39
40
|
# end
|
|
40
41
|
class SessionsResource
|
|
41
42
|
include Enumerable
|
|
43
|
+
include NotSerializableCollection
|
|
42
44
|
|
|
43
45
|
def initialize(client)
|
|
44
46
|
@client = client
|
data/lib/basecradle/timelines.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "pagination"
|
|
4
|
+
require_relative "serialization"
|
|
4
5
|
require_relative "timeline"
|
|
5
6
|
|
|
6
7
|
module BaseCradle
|
|
@@ -10,6 +11,7 @@ module BaseCradle
|
|
|
10
11
|
# method (+map+, +find+, +first+ — which stop early without fetching every page).
|
|
11
12
|
class TimelinesResource
|
|
12
13
|
include Enumerable
|
|
14
|
+
include NotSerializableCollection
|
|
13
15
|
|
|
14
16
|
def initialize(client)
|
|
15
17
|
@client = client
|
data/lib/basecradle/user.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "api_object"
|
|
4
|
+
require_relative "serialization"
|
|
4
5
|
|
|
5
6
|
module BaseCradle
|
|
6
7
|
# The trust relationship between you and another user, from your point of view.
|
|
@@ -98,6 +99,7 @@ module BaseCradle
|
|
|
98
99
|
# bc.users.each { |user| puts [user.handle, user.kind, user.trust.mutual].inspect }
|
|
99
100
|
class UsersResource
|
|
100
101
|
include Enumerable
|
|
102
|
+
include NotSerializableCollection
|
|
101
103
|
|
|
102
104
|
def initialize(client)
|
|
103
105
|
@client = client
|
data/lib/basecradle/version.rb
CHANGED
data/lib/basecradle/webhooks.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "api_object"
|
|
4
4
|
require_relative "items"
|
|
5
|
+
require_relative "serialization"
|
|
5
6
|
require_relative "user"
|
|
6
7
|
|
|
7
8
|
module BaseCradle
|
|
@@ -98,6 +99,14 @@ module BaseCradle
|
|
|
98
99
|
# that already happened. Converting away from this type gives up the folding too
|
|
99
100
|
# (+to_h+, <tt>{**headers}</tt>, and anything else that hands back a plain +Hash+);
|
|
100
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.
|
|
101
110
|
class WebhookEventHeaders < Hash
|
|
102
111
|
# Hash's own exact-match membership, kept under a private name before the case-folding
|
|
103
112
|
# +key?+ below takes the name: a caller who wrote the wire's own spelling is answered
|
|
@@ -166,6 +175,43 @@ module BaseCradle
|
|
|
166
175
|
->(name) { dig(name) }
|
|
167
176
|
end
|
|
168
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
|
+
|
|
169
215
|
private
|
|
170
216
|
|
|
171
217
|
# The wire's own spelling of +name+, or nil if no casing of it was delivered. Anything
|
|
@@ -260,6 +306,7 @@ module BaseCradle
|
|
|
260
306
|
# One timeline's webhook endpoints: create here, or iterate (newest first).
|
|
261
307
|
class TimelineWebhookEndpoints
|
|
262
308
|
include Enumerable
|
|
309
|
+
include NotSerializableCollection
|
|
263
310
|
|
|
264
311
|
def initialize(client, timeline_uuid)
|
|
265
312
|
@client = client
|
|
@@ -287,6 +334,7 @@ module BaseCradle
|
|
|
287
334
|
# One timeline's webhook events — read-only, so iterate is all there is.
|
|
288
335
|
class TimelineWebhookEvents
|
|
289
336
|
include Enumerable
|
|
337
|
+
include NotSerializableCollection
|
|
290
338
|
|
|
291
339
|
def initialize(client, timeline_uuid)
|
|
292
340
|
@client = client
|
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.
|
|
4
|
+
version: 0.10.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Drawk Kwast
|
|
@@ -26,6 +26,7 @@ files:
|
|
|
26
26
|
- lib/basecradle/errors.rb
|
|
27
27
|
- lib/basecradle/items.rb
|
|
28
28
|
- lib/basecradle/pagination.rb
|
|
29
|
+
- lib/basecradle/serialization.rb
|
|
29
30
|
- lib/basecradle/sessions.rb
|
|
30
31
|
- lib/basecradle/timeline.rb
|
|
31
32
|
- lib/basecradle/timelines.rb
|