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.
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "api_object"
4
+ require_relative "headers"
5
+ require_relative "pagination"
6
+ require_relative "serialization"
7
+ require_relative "user"
8
+
9
+ module BaseCradle
10
+ # Contact messages and notes are the platform's first **admin-only** surface. Every call
11
+ # in this file answers a non-admin with +NotAnAdminError+ (403, +not_an_admin+) — the
12
+ # SDK sends the request regardless, because the platform, not this library, decides who
13
+ # is an admin. +bc.me.admin+ is present only for an admin, if you want to ask first.
14
+
15
+ # --- models ---------------------------------------------------------------------------
16
+
17
+ # What a note is about: its subject's record type and uuid. Typed, because a note can be
18
+ # about any kind of record — today it is always a +"contact_message"+.
19
+ class Notable < ApiObject
20
+ attribute :type
21
+ attribute :uuid
22
+ end
23
+
24
+ # An admin's dated, signed remark about a record. A note **never changes**: there is no
25
+ # update and no delete, for anyone. One shape everywhere a note appears — fetched alone,
26
+ # listed, or embedded on its subject (+contact_message.notes+).
27
+ class Note < ApiObject
28
+ attribute :uuid
29
+ attribute :body
30
+ attribute :user, wrap: User # the author, nested-actor form
31
+ attribute :notable, wrap: Notable
32
+ attribute :created_at
33
+ attribute :updated_at
34
+ end
35
+
36
+ # A contact-page submission's request headers, exactly as the visitor's browser sent
37
+ # them — read through RequestHeaders' rules: case-insensitive lookup, and a render of
38
+ # names and never values, because a visitor's request can carry its own credentials.
39
+ class ContactMessageHeaders < RequestHeaders; end
40
+
41
+ # What someone sent through the public contact page, stored with everything the request
42
+ # told the platform and, after the fact, what the risk-assessment vendors said about it.
43
+ # A top-level record, so flat (no +type+ / +content+): +contact_message.uuid+ is its
44
+ # identity, and it embeds its notes in full.
45
+ #
46
+ # Nothing is ever rejected on a score — the record is the data, and an admin decides,
47
+ # with +update_status+ and +add_note+.
48
+ class ContactMessage < ApiObject
49
+ attribute :uuid
50
+ attribute :name
51
+ attribute :email_address
52
+ attribute :body
53
+ # "received" (the default) | "closed" | "spam" — the triage verdict, set with
54
+ # +update_status+. Treat an unrecognized value as forward-compatible, never an error.
55
+ attribute :status
56
+ # The signed-in user who submitted it (nested-actor form), or +nil+ for a visitor
57
+ # without an account.
58
+ attribute :user, wrap: User
59
+ attribute :ip_address
60
+ attribute :user_agent
61
+ attribute :headers, wrap: ContactMessageHeaders
62
+ attribute :honeypot_filled
63
+ attribute :fill_seconds # how long the form took to fill, or nil
64
+ # One self-describing slot per vendor (+vendor+, +api+, +docs+, +fetched_at+,
65
+ # +attempts+, then exactly one of +answer+ / +skipped+ / +error+). A plain Hash, read
66
+ # as the platform sent it — the vendors are the platform's to add and change, so the
67
+ # SDK models none of them. Note the scores do not all run the same way: see
68
+ # https://basecradle.com/docs/api.md#contact-messages.
69
+ attribute :data
70
+ attribute :notes, wrap: Note
71
+ attribute :created_at
72
+ attribute :updated_at
73
+
74
+ # Set the triage verdict: +"received"+, +"closed"+ or +"spam"+. It moves in any
75
+ # direction — reopening a closed message is +update_status("received")+.
76
+ #
77
+ # Live object: the API returns the whole contact message, and this object reads it from
78
+ # here on (+status+ and +updated_at+ included), then returns +self+. Raises
79
+ # +ValidationError+ (422) for a status outside the three.
80
+ def update_status(status)
81
+ response = require_client.request("PATCH", "/contact_messages/#{uuid}/status",
82
+ json: { "contact_message" => { "status" => status } })
83
+ @data = response.fetch("contact_message")
84
+ self
85
+ end
86
+
87
+ # Write a note about this contact message, and return it as a +Note+ (you are its
88
+ # author). Raises +ValidationError+ (422) for a blank body.
89
+ #
90
+ # This object's own +notes+ is not touched: it is the record as fetched, and the API
91
+ # answers with the note alone. Fetch the contact message again
92
+ # (+bc.contact_messages.get(uuid)+) to read it with the new note embedded.
93
+ #
94
+ # Never auto-retried — the notes endpoint takes no +Idempotency-Key+, so a resend after
95
+ # a lost response could write the note twice.
96
+ def add_note(body:)
97
+ response = require_client.request("POST", "/contact_messages/#{uuid}/notes",
98
+ json: { "note" => { "body" => body } })
99
+ Note.new(response.fetch("note"), client: @client)
100
+ end
101
+ end
102
+
103
+ # --- collections ----------------------------------------------------------------------
104
+
105
+ # Every contact message, newest first, auto-paginating — narrow with +.filter(status:)+,
106
+ # or fetch one by uuid. Admin-only.
107
+ #
108
+ # bc.contact_messages.filter(status: "received").each do |message|
109
+ # message.update_status("spam") if message.honeypot_filled
110
+ # end
111
+ class ContactMessagesResource
112
+ include Enumerable
113
+ include NotSerializableCollection
114
+
115
+ def initialize(client, filters: {})
116
+ @client = client
117
+ @filters = filters
118
+ end
119
+
120
+ def each(&block)
121
+ return enum_for(:each) unless block_given?
122
+
123
+ Paginator.new(@client, "/contact_messages", envelope_key: "contact_messages",
124
+ model: ContactMessage, params: @filters).each(&block)
125
+ end
126
+
127
+ # A new lazy resource narrowed by status: +"received"+, +"closed"+ or +"spam"+. Any
128
+ # other value is the API's to refuse (+InvalidFilterError+, 400).
129
+ def filter(status: nil)
130
+ filters = @filters.dup
131
+ filters["status"] = status unless status.nil?
132
+ self.class.new(@client, filters: filters)
133
+ end
134
+
135
+ # Fetch one contact message by its uuid.
136
+ def get(uuid)
137
+ ContactMessage.new(@client.request("GET", "/contact_messages/#{uuid}").fetch("contact_message"),
138
+ client: @client)
139
+ end
140
+ end
141
+
142
+ # Every note, across every subject, newest first, auto-paginating — or fetch one by uuid.
143
+ # Admin-only. Notes are written from their subject (+contact_message.add_note+), never
144
+ # from here.
145
+ class NotesResource
146
+ include Enumerable
147
+ include NotSerializableCollection
148
+
149
+ def initialize(client)
150
+ @client = client
151
+ end
152
+
153
+ def each(&block)
154
+ return enum_for(:each) unless block_given?
155
+
156
+ Paginator.new(@client, "/notes", envelope_key: "notes", model: Note).each(&block)
157
+ end
158
+
159
+ # Fetch one note by its uuid.
160
+ def get(uuid)
161
+ Note.new(@client.request("GET", "/notes/#{uuid}").fetch("note"), client: @client)
162
+ end
163
+ end
164
+ end
@@ -34,6 +34,13 @@ module BaseCradle
34
34
  attribute :change_password_url
35
35
  end
36
36
 
37
+ # The operator's surfaces — present on the Dashboard only for an admin.
38
+ class DashboardAdmin < ApiObject
39
+ attribute :contact_messages_url
40
+ attribute :notes_url
41
+ attribute :guide_url
42
+ end
43
+
37
44
  # One official SDK: where its code lives and where to install it from.
38
45
  class DashboardSdk < ApiObject
39
46
  attribute :repository
@@ -58,12 +65,17 @@ module BaseCradle
58
65
  end
59
66
 
60
67
  # Who am I, what is this place, where is everything — the answer every freshly-woken
61
- # peer asks first. Identity · environment · interaction · account · documentation.
68
+ # peer asks first. Identity · environment · interaction · account · documentation, and
69
+ # for an admin a sixth section, admin.
62
70
  class Dashboard < ApiObject
63
71
  attribute :identity, wrap: User
64
72
  attribute :environment, wrap: DashboardEnvironment
65
73
  attribute :interaction, wrap: DashboardInteraction
66
74
  attribute :account, wrap: DashboardAccount
75
+ # Present only for an admin — so the Dashboard's shape is how a peer learns its own
76
+ # role. Reading it as anyone else raises MissingFieldError rather than guessing;
77
+ # branch on +me.identity.admin?+ instead.
78
+ attribute :admin, wrap: DashboardAdmin
67
79
  attribute :documentation, wrap: DashboardDocumentation
68
80
  end
69
81
  end
@@ -30,10 +30,15 @@ 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.
33
+ # A Client or a collection resource was serialized — +to_json+ / +as_json+,
34
+ # +Marshal.dump+, or +to_yaml+ / +YAML.dump+. Neither is a record: a Client holds your
35
+ # bearer token, and a collection is a lazy query that holds the client and would page
36
+ # the whole API from inside your renderer. The SDK refuses loudly rather than emit a
37
+ # credential or a heap address — see BaseCradle::NotSerializable.
38
+ #
39
+ # It is also how a caller who serialized *neither* gets here: +Marshal+ and Psych
40
+ # recurse, so dumping a model reaches the client it holds and raises this, naming
41
+ # BaseCradle::Client. Dump the record's own data, +model.to_h+, which holds no client.
37
42
  class NotSerializableError < Error; end
38
43
 
39
44
  # The request never got an API response (DNS failure, refused connection, timeout).
@@ -75,6 +80,9 @@ module BaseCradle
75
80
  # author (an admin may act on any task).
76
81
  class NotTaskAuthorError < ForbiddenError; end
77
82
 
83
+ # +not_an_admin+ — the surface is admin-only (contact messages and notes).
84
+ class NotAnAdminError < ForbiddenError; end
85
+
78
86
  # --- 404 --------------------------------------------------------------------------------
79
87
 
80
88
  # +not_found+ — no record exists for the given UUID (or it is hidden from you).
@@ -140,6 +148,9 @@ module BaseCradle
140
148
  # +payload_too_large+ — webhook ingest: the request body exceeds the maximum size.
141
149
  class PayloadTooLargeError < Error; end
142
150
 
151
+ # +binary_payload+ — webhook ingest: the request body is not UTF-8 text (HTTP 415).
152
+ class BinaryPayloadError < Error; end
153
+
143
154
  # --- the code => class registry ----------------------------------------------------------
144
155
 
145
156
  # Every API error is an RFC 9457 application/problem+json document with a stable,
@@ -153,6 +164,7 @@ module BaseCradle
153
164
  "not_timeline_owner" => NotTimelineOwnerError,
154
165
  "timeline_locked" => TimelineLockedError,
155
166
  "not_task_author" => NotTaskAuthorError,
167
+ "not_an_admin" => NotAnAdminError,
156
168
  "not_found" => NotFoundError,
157
169
  "task_not_pending" => TaskNotPendingError,
158
170
  "validation_failed" => ValidationError,
@@ -162,7 +174,8 @@ module BaseCradle
162
174
  "invalid_cursor" => InvalidCursorError,
163
175
  "invalid_filter" => InvalidFilterError,
164
176
  "endpoint_disabled" => EndpointDisabledError,
165
- "payload_too_large" => PayloadTooLargeError
177
+ "payload_too_large" => PayloadTooLargeError,
178
+ "binary_payload" => BinaryPayloadError
166
179
  }.freeze
167
180
 
168
181
  class Error
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rendering"
4
+
5
+ module BaseCradle
6
+ # A recorded HTTP request's headers: the wire's own spelling, looked up
7
+ # case-insensitively. The platform records two kinds of inbound request verbatim — a
8
+ # webhook delivery (+WebhookEventHeaders+) and a contact-page submission
9
+ # (+ContactMessageHeaders+) — and both read through this one class's rules.
10
+ #
11
+ # A +Hash+ of exactly what the wire carried — one pair per header, +Content-Type+ and
12
+ # +Content-Length+ included — so iterating, +keys+, +to_h+, +to_json+ and +==+ all read
13
+ # the platform's own spelling and nothing is renamed. Only *lookup* folds case, because
14
+ # header names are case-insensitive by RFC and the platform does not preserve the
15
+ # sender's casing: it stores names canonicalized to Title-Case per segment
16
+ # (+X-Github-Delivery+, not +X-GitHub-Delivery+). So a vendor's published spelling finds
17
+ # the header it names, and so does any other casing of it:
18
+ #
19
+ # event.content.headers["X-GitHub-Delivery"] # GitHub's own published spelling
20
+ # event.content.headers["x-github-delivery"] # the lowercase form
21
+ # event.content.headers["X-Github-Delivery"] # what the wire actually carried
22
+ #
23
+ # Every read that takes a header name folds: +fetch+, +dig+, +values_at+, +fetch_values+,
24
+ # +to_proc+ and +key?+ (with +has_key?+, +include?+ and +member?+). A header that was
25
+ # genuinely not sent is *absent*, never +nil+ — +[]+ and a fallback-less +fetch+ or
26
+ # +fetch_values+ raise +KeyError+ naming the headers that did arrive, while +fetch+ with
27
+ # a fallback, +dig+ and +values_at+ answer for it the way +Hash+ does.
28
+ #
29
+ # The methods that *reshape* the hash rather than read one name — +slice+, +except+,
30
+ # +select+, +transform_values+ — and the mutators are left exactly as +Hash+ defines
31
+ # them: case-sensitive, on the platform's own spelling. This is a read of one request
32
+ # that already happened. Converting away from this type gives up the folding too
33
+ # (+to_h+, <tt>{**headers}</tt>, and anything else that hands back a plain +Hash+);
34
+ # +merge+ and +dup+ keep it.
35
+ #
36
+ # What it does *not* print is the values: +inspect+, +to_s+ and +pp+ render the header
37
+ # names alone, because these are the sender's headers and one of them may be the
38
+ # sender's credential. Every read is unaffected — and so is +to_json+, which still
39
+ # emits the request verbatim. Converting away from this type gives the redaction up
40
+ # exactly as it gives up the folding, and for the same reason: +slice+, +except+,
41
+ # +select+, +transform_values+ and +to_h+ hand back a plain +Hash+, whose +inspect+
42
+ # prints pairs.
43
+ class RequestHeaders < Hash
44
+ # Hash brings its own render, and a header value is another party's credential. The
45
+ # module puts this class back under the SDK-wide rule; it sits between this class and
46
+ # Hash in the ancestor chain, so all three doors resolve to it.
47
+ include RendersNamesOnly
48
+
49
+ # Hash's own exact-match membership, kept under a private name before the case-folding
50
+ # +key?+ below takes the name: a caller who wrote the wire's own spelling is answered
51
+ # without a scan, and it is the tiebreak if a derived copy holds two casings of one
52
+ # name.
53
+ alias_method :exact_key?, :key?
54
+ private :exact_key?
55
+
56
+ NO_FALLBACK = Object.new.freeze
57
+ private_constant :NO_FALLBACK
58
+
59
+ # +client:+ is unused — headers carry no verbs — but every wrapped model is built with
60
+ # it (see ApiObject.attribute's +wrap:+), so it is accepted.
61
+ def initialize(data, client: nil)
62
+ super()
63
+ update(data)
64
+ end
65
+
66
+ # The header's value, matched case-insensitively. Raises +KeyError+ when no casing of
67
+ # the name was delivered.
68
+ def [](name)
69
+ wire = wire_name(name)
70
+ wire.nil? ? raise_missing(name) : super(wire)
71
+ end
72
+
73
+ # Hash-like: the value, or the fallback — a default argument, or a block, which is
74
+ # handed the name as written — when the header was not delivered. With no fallback it
75
+ # behaves like +[]+.
76
+ def fetch(name, fallback = NO_FALLBACK, &block)
77
+ wire = wire_name(name)
78
+ return super(wire) if wire
79
+ return yield(name) if block
80
+ return fallback unless fallback.equal?(NO_FALLBACK)
81
+
82
+ raise_missing(name)
83
+ end
84
+
85
+ # Did this request carry the header, under any casing?
86
+ def key?(name)
87
+ !wire_name(name).nil?
88
+ end
89
+ alias_method :has_key?, :key?
90
+ alias_method :include?, :key?
91
+ alias_method :member?, :key?
92
+
93
+ # Ruby's nil-returning read, case folded: the value, or +nil+ for a header that was
94
+ # not delivered.
95
+ def dig(name, *rest)
96
+ wire = wire_name(name)
97
+ wire.nil? ? nil : super(wire, *rest)
98
+ end
99
+
100
+ # Several headers at once, folded the same way — +nil+ for one that was not delivered,
101
+ # as +Hash+ answers for a key it has no value for.
102
+ def values_at(*names)
103
+ names.map { |name| dig(name) }
104
+ end
105
+
106
+ # As +values_at+, but a header that was not delivered raises, exactly as +fetch+ does.
107
+ def fetch_values(*names, &block)
108
+ names.map { |name| block ? fetch(name, &block) : fetch(name) }
109
+ end
110
+
111
+ # +&headers+ — a header name to its value, folded, +nil+ for one not delivered.
112
+ def to_proc
113
+ ->(name) { dig(name) }
114
+ end
115
+
116
+ private
117
+
118
+ # The header *names* this request carried, and none of their values.
119
+ #
120
+ # +Hash+'s own +inspect+ prints every pair, and these headers are the *sender's*,
121
+ # stored verbatim by the platform: a sender that authenticates its POST to an ingest
122
+ # URL puts its credential in one of them, and a contact-page visitor's browser may
123
+ # carry its own cookies or tokens. So +logger.debug(event.content.headers)+ or
124
+ # +logger.debug(message.headers)+ would write another party's secret into our logs.
125
+ #
126
+ # Only the human-facing render changes. Every *read* is untouched and still
127
+ # wire-exact: lookups, +fetch+, +each+, +keys+, +to_h+, +to_json+, +==+.
128
+ #
129
+ # Names are sorted by their string form — a derived copy can hold a key that never
130
+ # came off the wire, and a mixed-type +keys+ has no natural order otherwise.
131
+ #
132
+ # +nil+ for a request the platform recorded with no headers at all, so it renders as
133
+ # the bare class. That is asked of the hash, not of the joined string: a request
134
+ # carrying a single header whose *name* is the empty string joins to "" and must stay
135
+ # distinguishable from one carrying nothing.
136
+ def render_body
137
+ return nil if empty?
138
+
139
+ keys.sort_by(&:to_s).join(", ")
140
+ end
141
+
142
+ # The wire's own spelling of +name+, or nil if no casing of it was delivered. Anything
143
+ # but a String is not a header name, so it is never matched rather than being coerced
144
+ # into one: +headers[:host]+ finds nothing, and so misses like any other absent name.
145
+ # Keys are type-checked too — +merge+ hands back one of these, so a caller's derived
146
+ # copy can hold a key that never came off the wire.
147
+ def wire_name(name)
148
+ return nil unless name.is_a?(String)
149
+ return name if exact_key?(name)
150
+
151
+ each_key { |wire| return wire if wire.is_a?(String) && wire.casecmp?(name) }
152
+ nil
153
+ end
154
+
155
+ def raise_missing(name)
156
+ raise KeyError.new(
157
+ "No #{name.inspect} header on this request. Header names are matched " \
158
+ "case-insensitively, so no casing of it was sent either. " \
159
+ "Headers present: #{keys.sort_by(&:to_s).inspect}",
160
+ receiver: self, key: name
161
+ )
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BaseCradle
4
+ # Print what an object *is*, never what it holds.
5
+ #
6
+ # Every renderable object in this SDK follows one rule — a render shows field names and
7
+ # never field values — and the rule is what keeps secrets out of logs, REPL transcripts,
8
+ # exception messages and +p+ calls. It is load-bearing in three separate places:
9
+ # +Client+ holds a live +bc_uat_+ token; a +WebhookEventHeaders+ holds whatever an
10
+ # inbound sender put in its headers, which is another party's credential; and an
11
+ # +ApiObject+ holds a record's values, including an endpoint's +ingest_url+.
12
+ #
13
+ # The rule used to exist as three hand-rolled copies of the same three methods, with no
14
+ # fourth class ever being asked the question. That is exactly how the leak in 0.10.2
15
+ # arrived: +WebhookEventHeaders+ was added as a +Hash+ descendant, +Hash+ brought its
16
+ # own render, and every value printed with the suite green. Here it is one module, so a
17
+ # class added later either includes it or fails the reflective guard in
18
+ # +test/basecradle/rendering_test.rb+.
19
+ #
20
+ # Three doors, because a render arrives through three:
21
+ #
22
+ # - +inspect+ — +p+, a REPL echo, an exception's backtrace context, a nested +inspect+.
23
+ # - +to_s+ — string interpolation and +puts+, which is the door people reach for without
24
+ # thinking about it. +Hash+ aliases +to_s+ to its *own* +inspect+ as one shared method
25
+ # entry, so overriding +inspect+ alone leaves <tt>"#{headers}"</tt> printing values.
26
+ # - +pretty_print+ — +pp+. +pp+ uses an object's own +inspect+ when it defines one, which
27
+ # is why an +ApiObject+ was already safe here and only a +Hash+ descendant was not;
28
+ # delegating makes that a property of the module rather than a coincidence of which
29
+ # superclass a class happens to have.
30
+ #
31
+ # +to_s+ is late-bound to +inspect+ rather than written <tt>alias to_s inspect</tt>: an
32
+ # alias copies the method body at alias time, so a subclass that redacts more in
33
+ # +inspect+ would be bypassed by interpolation.
34
+ module RendersNamesOnly
35
+ def inspect
36
+ body = render_body
37
+ body.nil? ? "#<#{self.class}>" : "#<#{self.class} #{body}>"
38
+ end
39
+
40
+ def to_s
41
+ inspect
42
+ end
43
+
44
+ def pretty_print(printer)
45
+ printer.text(inspect)
46
+ end
47
+
48
+ private
49
+
50
+ # What goes inside the angle brackets after the class name — names, never values.
51
+ # Includers supply it.
52
+ #
53
+ # +nil+ means "there is nothing to show" and renders the bare <tt>#<Class></tt>;
54
+ # anything else is rendered after a space, the empty string included. That
55
+ # distinction is not pedantry: a webhook delivery carrying one header whose *name* is
56
+ # the empty string is not the same delivery as one carrying no headers, and folding
57
+ # the two would report a header that arrived as a header that did not. So emptiness
58
+ # is the includer's question about its own contents, never this module's question
59
+ # about the string it was handed.
60
+ #
61
+ # The default raises rather than returning something bland, because a silent fallback
62
+ # would render an object as its class name alone and look deliberate; this SDK would
63
+ # rather fail in the test that includes the module.
64
+ def render_body
65
+ raise NotImplementedError,
66
+ "#{self.class} includes BaseCradle::RendersNamesOnly but defines no " \
67
+ "#render_body, so there is nothing for #inspect to print. Supply one " \
68
+ "returning field names — never field values."
69
+ end
70
+ end
71
+ end
@@ -18,6 +18,17 @@ module BaseCradle
18
18
  # Without ActiveSupport the same calls were merely useless: +"#<BaseCradle::Client:0x…>"+,
19
19
  # a heap address that differs every run.
20
20
  #
21
+ # +Marshal+ and Psych are that same act through two doors ActiveSupport never touched.
22
+ # Both walk instance variables directly and neither consults +as_json+, so closing the
23
+ # JSON door left them open, needing no ActiveSupport to be reachable. They are also the
24
+ # worse pair, because they are what puts a credential *at rest*: ActiveSupport's cache
25
+ # stores marshal what you write, so <tt>Rails.cache.write("bc", bc)</tt> landed the
26
+ # token in Redis, memcached or a file; a Marshal-backed session landed it in the
27
+ # session; Delayed::Job YAMLs its handler into the database. And nothing shadows the
28
+ # ivar walk the way +Enumerable#as_json+ shadows +Object#as_json+ — so
29
+ # <tt>Marshal.dump(bc.messages)</tt> reached the token where <tt>bc.messages.to_json</tt>
30
+ # never did. Both doors now raise, on the same mixin, so every includer is covered.
31
+ #
21
32
  # Includers supply +serialization_refusal+; the default below is the honest fallback
22
33
  # for one that does not, so the failure is still a rescuable BaseCradle::Error rather
23
34
  # than a NoMethodError out of a renderer.
@@ -34,6 +45,24 @@ module BaseCradle
34
45
  raise NotSerializableError, serialization_refusal
35
46
  end
36
47
 
48
+ # +Marshal.dump+, and so every store built on it: ActiveSupport's cache backends, a
49
+ # Marshal-backed session, and the <tt>Marshal.load(Marshal.dump(x))</tt> deep-copy
50
+ # idiom. Marshal reaches for this hook before it walks ivars, so defining it is what
51
+ # stops the walk.
52
+ #
53
+ # Deliberately unpaired with a +marshal_load+: the pair only matters to something
54
+ # being loaded, and nothing can be loaded because nothing is ever dumped.
55
+ def marshal_dump
56
+ raise NotSerializableError, serialization_refusal
57
+ end
58
+
59
+ # Psych's hook — +to_yaml+, +YAML.dump+, and anything either one nests. Psych prefers
60
+ # +encode_with+ over its own ivar walk for any object that defines it. The coder is
61
+ # unused: it is the thing we decline to fill.
62
+ def encode_with(_coder)
63
+ raise NotSerializableError, serialization_refusal
64
+ end
65
+
37
66
  private
38
67
 
39
68
  def serialization_refusal
@@ -59,16 +88,31 @@ module BaseCradle
59
88
 
60
89
  private
61
90
 
62
- # Deliberately does *not* claim a token leak. Measured on this tree: ActiveSupport's
91
+ # One message, four doors — and it names both harms because which one you get depends
92
+ # on the door, measured on this tree. Through JSON, ActiveSupport's
63
93
  # +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.
94
+ # fetched records and the client in its ivars was never walked: the harm is the
95
+ # unbounded fetch. +Marshal+ and Psych have no such shadow and walked the ivars
96
+ # instead, reaching the token and writing it wherever the dump went.
97
+ #
98
+ # Naming only the fetch would have told a +Rails.cache.write+ caller their credential
99
+ # was slow to write rather than at rest in Redis — a wrong diagnosis being worse than
100
+ # a missing one. Naming both keeps one refusal per resource, which is what stops the
101
+ # doors drifting apart.
102
+ #
103
+ # The remedy is two-part for the same reason. <tt>.to_a</tt> alone is enough for JSON,
104
+ # where a model serializes as its record — but the array it hands back is full of
105
+ # models that each hold the client, so <tt>Marshal.dump(bc.messages.to_a)</tt> lands
106
+ # straight back here. Measured, not assumed.
66
107
  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."
108
+ "#{self.class} is a lazy, auto-paginating query holding your connection, not a " \
109
+ "record, so there is no one record to serialize. Serializing it either runs a " \
110
+ "page-by-page GET loop over the whole resource from inside your renderer and " \
111
+ "emits every record it fetched, or writes out the connection it holds — your " \
112
+ "bc_uat_ token with it. Call .to_a (or .first(n), or .filter(...).to_a) and " \
113
+ "serialize that — then how much you fetch is a visible act in your own code. " \
114
+ "Marshal and YAML reach the client through the records too, so dump the wire " \
115
+ "records rather than the models: .to_a.map(&:to_h)."
72
116
  end
73
117
  end
74
118
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BaseCradle
4
- VERSION = "0.10.2"
4
+ VERSION = "0.11.0"
5
5
  end