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
|
@@ -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
|
data/lib/basecradle/dashboard.rb
CHANGED
|
@@ -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
|
data/lib/basecradle/errors.rb
CHANGED
|
@@ -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
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
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
|
-
#
|
|
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
|
|
65
|
-
#
|
|
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
|
|
68
|
-
"record to serialize. Serializing it runs a
|
|
69
|
-
"resource from inside your renderer
|
|
70
|
-
"
|
|
71
|
-
"
|
|
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
|
data/lib/basecradle/version.rb
CHANGED