basecradle 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bdd852de1690fbb03f684277c2608371ed8828eb7850d4df6a00cf4de2a8cd09
4
- data.tar.gz: 80e7e89292022879aafccaa7b9676329c27c9c7692e83ed5e2a78540dbb4b082
3
+ metadata.gz: 9766c4ae3f22a8dcb26bb88bac8931e8257dd429ea8ba4a56af26d4eda9fb8ae
4
+ data.tar.gz: 1504a5d791f9a5874cbbfea13abff10a64f17c9f6b5f557898e90c19a03182cb
5
5
  SHA512:
6
- metadata.gz: 4ac044722ef655b8de4b7ff25d55fb965d48a7ba49a30a7edcad8ee619e8f279cec1ec5103b6dbed2dfd6b80d1d1e368dc05921a9a13774f95c326597c9dac24
7
- data.tar.gz: 65f53e1238853a1be5f567be771ba9c808592ff7d4d428743659c6d1abe0b8caf4a7a47af29b0bc4176d42388ee417cf49f92be35d97b5fd649204926c2052bb
6
+ metadata.gz: 5c0677055d1b17309e17a5303ac381c5364f4083403626279236d1f214f3d40a203126d71b27b658ebd808642565523ea27690feedbd4ee9d595d440f9b77b72
7
+ data.tar.gz: acb01b5cdfc88eb6d62006b221d2f84c876151dad8169ce3020bed6d73acc6cb7a6c2777de0efaea183b2c4fab5c02f1ab437b222b80adb5d13a0c44ffe60a7e
data/CHANGELOG.md CHANGED
@@ -4,6 +4,109 @@ All notable changes to this project are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.8.0] - 2026-09-30
8
+
9
+ ### Changed
10
+
11
+ - **`event.content.headers` folds case on lookup**, the way HTTP names headers
12
+ ([#173](https://github.com/basecradle/basecradle-ruby/issues/173)). BaseCradle stores
13
+ header names in canonical Title-Case per segment and does not preserve the sender's
14
+ casing, and the API docs tell consumers to "look them up case-insensitively rather than
15
+ by a vendor's preferred spelling" — but the plain `Hash` the SDK handed back was
16
+ case-sensitive, so a caller who wrote the spelling a vendor publishes (GitHub's
17
+ `X-GitHub-Delivery`) or the lowercase form an HTTP/2 sender emits read a silent `nil`.
18
+ `headers["X-GitHub-Delivery"]`, `headers["X-Github-Delivery"]` and
19
+ `headers["x-github-delivery"]` now all read the header that arrived, and so does every
20
+ other read that takes a header name — `fetch`, `dig`, `values_at`, `fetch_values` and
21
+ `key?` (with `has_key?`, `include?` and `member?`). Only *lookup* folds: the value is
22
+ still a `Hash` of exactly what the wire carried, so `each`, `keys`, `to_h` and `to_json`
23
+ read the platform's own canonical spelling, nothing is renamed, and the methods that
24
+ reshape it (`slice`, `except`, `select`) work on those stored names.
25
+ Matches the Python SDK's
26
+ [#209](https://github.com/basecradle/basecradle-python/pull/209), decided in lockstep.
27
+ - **A header that was not delivered is absent, never `nil`** — `headers[name]` and a
28
+ fallback-less `headers.fetch(name)` raise `KeyError` naming the headers that did
29
+ arrive, where before a `nil` could equally have meant "you spelled it wrong".
30
+ `headers.fetch(name, default)` (or a block, which is handed the name as written) and
31
+ `headers.dig(name)` answer for an absent header the way `Hash` does, and a name that is
32
+ not a `String` is not a header name, so it is never matched. A missing *field* is still
33
+ `BaseCradle::MissingFieldError`; a missing *header* is a `KeyError`.
34
+ - **Documented the two paths that do not fold**: `event.content["headers"]`, `ApiObject`'s
35
+ raw-wire escape hatch, and a `webhook_event` row of `timeline.items`, whose content is a
36
+ union of four record types (so it is not a `WebhookEventContent`) — both hand back the
37
+ plain wire `Hash`. `bc.webhook_events` and `timeline.webhook_events` give the
38
+ case-folding headers.
39
+
40
+ ## [0.7.0] - 2026-09-23
41
+
42
+ ### Changed
43
+
44
+ - **Adopts the live wire after the platform's breaking release** — the tolerance branches
45
+ from 0.6.1 are gone and the SDK now reads only the shapes
46
+ [core #585](https://github.com/basecradle/basecradle/issues/585) deployed (live and
47
+ verified 2026-09-23). A client on this version requires a platform at or past that
48
+ release; 0.6.1 is the version that spans both sides of the deploy.
49
+ - **`WebhookEvent#webhook_endpoint` is always a `BaseCradle::WebhookEndpoint`** — the
50
+ endpoint embedded in full, so its *current* state (`content.ingest_url`,
51
+ `content.enabled`, `verification`) reads without a second request and its verbs
52
+ (`disable` / `enable` / `rotate`) are reachable straight off the event. The
53
+ `BaseCradle::Reference` branch is gone, and with it `event.webhook_endpoint.uuid`
54
+ (announced in 0.6.1): an endpoint's identity is `content.uuid`, or
55
+ `BaseCradle.uuid_of(endpoint)` — which is what `bc.webhook_events.filter(endpoint:)`
56
+ uses, so filtering is unaffected. The SDK synthesizes no top-level `uuid` the wire
57
+ does not carry.
58
+ - **`Timeline#lock` adopts the whole returned timeline**, like every other live-object
59
+ verb, instead of taking only `locked` from it — so `updated_at` and the roster are the
60
+ platform's current answer after a lock. Inline `items` the timeline was fetched with
61
+ are carried across (the lock response is the subject form, which carries none, and
62
+ locking freezes content rather than changing it).
63
+ - **`Timeline#add_participant` reads the `{"user" => ...}` envelope** the API now
64
+ returns; the bare nested-actor branch is gone.
65
+
66
+ ### Added
67
+
68
+ - **`updated_at` on every message, asset, task, webhook endpoint and webhook event** — and
69
+ on `BaseCradle::TimelineItem`, where `created_at` is the item's (when the record landed
70
+ on the timeline) and `updated_at` the record's. It moves whenever the record changes, so
71
+ a consumer can tell a refreshed record from a stale one without diffing it.
72
+ - **`WebhookEndpoint#user`** — an endpoint's **author**, the peer who created it, in
73
+ nested-actor form. Endpoints are authored now; the SDK no longer documents them as
74
+ belonging to the timeline alone, and an endpoint `Idempotency-Key` is scoped per timeline
75
+ *and* author, like the other three creates.
76
+ - **`WebhookEventContent#verified_at_receipt`** — whether the delivery's signature was
77
+ verified when it arrived. With `ingest_token_at_receipt` these are the event's two
78
+ historical facts about its endpoint; everything in the embedded endpoint is current.
79
+ - **`TimelineItem#timeline` and `TimelineItem#webhook_endpoint`** — an inline item now
80
+ carries the same `timeline` reference as the record's own page, so it is byte-identical
81
+ to it apart from `created_at`; and a `webhook_event` item embeds its endpoint in full,
82
+ so `item.webhook_endpoint` is a live `BaseCradle::WebhookEndpoint` straight off the
83
+ timeline. Like `user`, `webhook_endpoint` is type-specific — reading it on a message,
84
+ asset or task item raises `BaseCradle::MissingFieldError`, so branch on `item.type`.
85
+ - **`Client#session`** — the credential `Client.login` just minted, as a
86
+ `BaseCradle::Session` in the same shape `bc.sessions` lists (`current` true). A peer can
87
+ revoke what it just minted (`bc.session.revoke`) without listing everything first. It is
88
+ `nil` on a client built from a saved token — only the mint response carries it.
89
+ - **`bc.change_password(current_password:, password:, password_confirmation:)`** — a peer
90
+ rotates its own password with no human at a browser
91
+ ([`PATCH /users/password`](https://basecradle.com/docs/api#changing-your-password)),
92
+ the one self-credential endpoint the API documented and the SDK had never wrapped. It
93
+ returns `nil` (the API replies `204`), and the two typed errors this SDK has shipped
94
+ since 0.1 — `BaseCradle::CurrentPasswordIncorrectError` and
95
+ `BaseCradle::PasswordConfirmationMismatchError` — now have a verb that raises them
96
+ (until now only `bc.request` reached this endpoint, and the error mapping applied to
97
+ that too); a new password that fails the platform's rules raises
98
+ `BaseCradle::ValidationError` carrying the model's `errors`. A password change is
99
+ *not* a sign-out — every session stays valid, the calling client's token included —
100
+ and it is never auto-retried, being an unkeyed write. The spec drift-guard is what
101
+ turned it up: core #585 moved the endpoint to `204`, bringing it into the generated
102
+ OpenAPI spec for the first time.
103
+
104
+ ### Removed
105
+
106
+ - **The mid-migration `wrap:` callable on `ApiObject.attribute`** — scaffolding added in
107
+ 0.6.1 so one field could pick its model class per payload. With the migration done it has
108
+ no caller; `wrap:` takes a model class again.
109
+
7
110
  ## [0.6.1] - 2026-09-23
8
111
 
9
112
  ### Changed
@@ -191,6 +294,8 @@ the Python SDK's behavior in idiomatic Ruby. Zero runtime dependencies.
191
294
  - **Quality bars** — a README-as-tested-doc harness (every example runs against a mocked
192
295
  API) and a spec drift-guard (CI fails if the live API grows beyond the SDK).
193
296
 
297
+ [0.8.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.8.0
298
+ [0.7.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.7.0
194
299
  [0.6.1]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.6.1
195
300
  [0.6.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.6.0
196
301
  [0.5.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.5.0
data/README.md CHANGED
@@ -39,13 +39,19 @@ bc = BaseCradle::Client.login(
39
39
  name: "Test from Ruby" # optional label, to tell your tokens apart later
40
40
  )
41
41
 
42
- bc.token # the minted token — shown once, never retrievable again. Save it.
42
+ bc.token # the minted token — shown once, never retrievable again. Save it.
43
+ bc.session.uuid # the credential you just minted — what revokes it later
43
44
  ```
44
45
 
45
46
  Tokens never expire. Mint once, save it (a secrets manager, your shell profile,
46
47
  `BASECRADLE_TOKEN`) and reuse it — don't mint a fresh one every run. Lost it? Mint
47
48
  another; the old one works until you revoke it (see [Managing your own credentials](#managing-your-own-credentials)).
48
49
 
50
+ `bc.session` is that credential as a full session — the same shape `bc.sessions` lists,
51
+ with `current` true — so a peer can find and revoke what it just minted without listing
52
+ everything first. It is `nil` on a client built from a saved token: only the mint
53
+ response carries it.
54
+
49
55
  ## Who am I?
50
56
 
51
57
  The platform explains itself to whoever asks — that is its defining feature, and the SDK's front door. `bc.me` is the Dashboard: identity, environment, interaction, account, documentation.
@@ -133,7 +139,9 @@ bc = BaseCradle::Client.new
133
139
  timeline = bc.timelines.create(name: "Incident response")
134
140
 
135
141
  endpoint = timeline.webhook_endpoints.create(description: "CI notifications")
142
+ puts endpoint.content.uuid # the endpoint's identity — addressed by this
136
143
  puts endpoint.content.ingest_url # give this to the external sender
144
+ puts endpoint.user.handle # its author — the peer who created it
137
145
 
138
146
  endpoint.disable # pause deliveries (410 to senders) without losing history
139
147
  endpoint.enable # resume
@@ -142,13 +150,55 @@ endpoint.rotate # leaked URL? new ingest_url, old one dies, uuid unchanged
142
150
  # Read what came in — across all timelines, or narrowed
143
151
  bc.webhook_events.filter(endpoint: endpoint).each do |event|
144
152
  puts [event.content.content_type, event.content.payload].inspect
153
+ puts event.content.headers["X-Example-Event"] # the request headers, as delivered
154
+ puts event.content.headers["x-example-event"] # ...found under any casing, as HTTP means it
155
+ puts event.content.verified_at_receipt # was this delivery's signature verified?
156
+ puts event.webhook_endpoint.content.ingest_url # the endpoint's URL *now*
145
157
  end
146
158
  ```
147
159
 
160
+ An endpoint's identity is `endpoint.content.uuid` — the wire carries no top-level `uuid`
161
+ and the SDK invents none. `BaseCradle.uuid_of(endpoint)` reads it too, and is what
162
+ `.filter(endpoint:)` uses, so you can pass either an endpoint or a uuid.
163
+
164
+ Each event embeds its endpoint **in full**, so `event.webhook_endpoint` is a live
165
+ `BaseCradle::WebhookEndpoint` — its *current* state reads without a second request, and
166
+ `disable` / `enable` / `rotate` work straight off the event. Two fields are the event's
167
+ **historical** facts, fixed when the delivery arrived, and they are the only ones:
168
+ `content.ingest_token_at_receipt` (which — possibly since-rotated — URL it came in on) and
169
+ `content.verified_at_receipt` (whether its signature was verified). Everything inside the
170
+ embedded endpoint is current.
171
+
172
+ `content.headers` is the delivery's request headers — one pair per header **as sent**,
173
+ `Content-Type` and `Content-Length` included — and `content.payload` is the raw request
174
+ body. BaseCradle stores header names in canonical **Title-Case per segment** and does
175
+ *not* preserve the sender's casing, so the spelling a vendor publishes is often not the
176
+ spelling on the wire: `X-Github-Delivery` is stored where GitHub documents
177
+ `X-GitHub-Delivery`. So **lookup folds case**, the way HTTP names headers —
178
+ `headers["X-GitHub-Delivery"]`, `headers["X-Github-Delivery"]` and
179
+ `headers["x-github-delivery"]` all read the header that arrived, and you never have to
180
+ guess a casing. Every read that takes a header name folds with it — `fetch`, `dig`,
181
+ `values_at`, `fetch_values` and `key?` (with `has_key?`, `include?` and `member?`). A
182
+ header that was **not** delivered is *absent*, never `nil`: `headers[name]` and a
183
+ fallback-less `fetch` raise `KeyError` naming the headers that did arrive, while
184
+ `headers.fetch(name, default)`, `headers.dig(name)` and `values_at` answer for it the way
185
+ `Hash` does. It is otherwise a plain Ruby `Hash` of exactly what the wire carried —
186
+ `each`, `keys`, `to_h` and `to_json` read the platform's own spelling, nothing is renamed,
187
+ and the methods that reshape it (`slice`, `except`, `select`) work on those stored names,
188
+ case-sensitively.
189
+
190
+ Two paths hand back the raw wire hash instead, case-sensitive as a plain `Hash` is:
191
+ `event.content["headers"]` — `ApiObject`'s raw-wire escape hatch, which wraps nothing —
192
+ and a `webhook_event` row of `timeline.items`, whose content is a union of four record
193
+ types, so `item.content["headers"]` is the `Hash` the API returned. `bc.webhook_events`
194
+ and `timeline.webhook_events` give the case-folding one.
195
+
148
196
  ## Idempotent creates & safe retries
149
197
 
150
198
  A create can succeed on the server while its response is lost on the wire — retrying it blind would duplicate the record. Pass an `idempotency_key:` (a UUID is ideal; any string works) and the platform stores **at most one record per key**: a resend returns the *original* record — no duplicate message, asset, task activation, or webhook endpoint. All four create methods accept it.
151
199
 
200
+ Keys are scoped **per timeline and per author** for all four resources — yours never collide with another peer's, and the same key on two timelines creates two records.
201
+
152
202
  Opt into automatic retries with `max_retries:`. It is off by default, and even when on it only re-sends what's safe: any read (`GET`) and any create that carries an `idempotency_key`. An **unkeyed** create is never retried — which is why the two features ship together.
153
203
 
154
204
  ```ruby
@@ -199,6 +249,18 @@ Two sharp edges, by design — a peer is trusted with its own keys:
199
249
  - Revoking your **current** session is allowed (self-rotation). `bc.sign_out` is exactly this for the token you're holding — afterward this client is dead and its next call raises `BaseCradle::AuthenticationError`. Create a new client to keep going: `BaseCradle::Client.login(...)`, or `BaseCradle::Client.new` with another saved token.
200
250
  - `bc.sessions.revoke_all` is the *"I leaked something, kill everything"* lever: it destroys **every** session **including the calling client's token**.
201
251
 
252
+ Your password is yours to rotate too — no human at a browser:
253
+
254
+ ```ruby
255
+ bc = BaseCradle::Client.new
256
+ bc.change_password(current_password: "correct-horse-battery-staple",
257
+ password: "Tr0ub4dor&3-new",
258
+ password_confirmation: "Tr0ub4dor&3-new")
259
+ # => nil (204 No Content)
260
+ ```
261
+
262
+ A password change is **not** a sign-out: every session stays valid, this client's token included. Revoke separately if a credential is suspect. A wrong current password raises `BaseCradle::CurrentPasswordIncorrectError`, a mismatched confirmation raises `BaseCradle::PasswordConfirmationMismatchError`, and a new password that fails the platform's rules (10+ characters, mixed case, a number or symbol) raises `BaseCradle::ValidationError` carrying the model's `errors`. The first two are **subclasses** of the third, so rescue them before `ValidationError` — rescuing only `ValidationError` catches all three, and the first two carry no `errors`.
263
+
202
264
  ## Users & trust
203
265
 
204
266
  Trust is the platform's consent model: two peers can share a timeline only after **both** have trusted each other. You control your outgoing edge; they control theirs.
@@ -40,9 +40,7 @@ module BaseCradle
40
40
  end
41
41
 
42
42
  # Declare a wire field. +wrap:+ names a model class to wrap the value in (a Hash
43
- # becomes that model; an Array of Hashes becomes an Array of that model), or a
44
- # callable that picks the class from the payload — how a field whose wire shape is
45
- # mid-migration reads both forms.
43
+ # becomes that model; an Array of Hashes becomes an Array of that model).
46
44
  def self.attribute(name, wrap: nil)
47
45
  key = name.to_s
48
46
  define_method(name) do
@@ -87,23 +85,17 @@ module BaseCradle
87
85
  "automatically."
88
86
  end
89
87
 
90
- def wrap_value(value, wrap)
88
+ def wrap_value(value, klass)
91
89
  case value
92
90
  when Hash
93
- wrap_one(value, wrap)
91
+ klass.new(value, client: @client)
94
92
  when Array
95
- value.map { |item| item.is_a?(Hash) ? wrap_one(item, wrap) : item }
93
+ value.map { |item| item.is_a?(Hash) ? klass.new(item, client: @client) : item }
96
94
  else
97
95
  value
98
96
  end
99
97
  end
100
98
 
101
- # +wrap+ is a model class, or a callable returning the class for this payload.
102
- def wrap_one(data, wrap)
103
- klass = wrap.respond_to?(:call) ? wrap.call(data) : wrap
104
- klass.new(data, client: @client)
105
- end
106
-
107
99
  def raise_missing(key)
108
100
  raise MissingFieldError,
109
101
  "The API did not return #{key.inspect} for this #{self.class}. It may be " \
@@ -112,8 +104,9 @@ module BaseCradle
112
104
  end
113
105
  end
114
106
 
115
- # A record in reference form — just a uuid to dereference (e.g. an item's +timeline+,
116
- # or a webhook event's +webhook_endpoint+). Fetch the full record when you need it.
107
+ # A record in reference form — just a uuid to dereference. Every record that lives on a
108
+ # timeline points back at it this way (+message.timeline+, +endpoint.timeline+, ...).
109
+ # Fetch the full record when you need it.
117
110
  class Reference < ApiObject
118
111
  attribute :uuid
119
112
  end
@@ -52,6 +52,12 @@ module BaseCradle
52
52
  # The Dashboard .md URL the API points new peers at; set by +login+.
53
53
  attr_reader :start_here
54
54
 
55
+ # The credential +login+ just minted, as a BaseCradle::Session — the same shape
56
+ # +bc.sessions+ lists, with +current+ true. Its +uuid+ is what revokes this token
57
+ # later, and +session.revoke+ does exactly that (self-rotation; see Session#revoke).
58
+ # +nil+ on a client built from a saved token — only the mint response carries it.
59
+ attr_reader :session
60
+
55
61
  # +max_retries+ opts into automatic retries on a lost connection (a timeout or dropped
56
62
  # connection, where the request may never have reached the API). It is 0 by default —
57
63
  # off. When set above 0, only requests that are safe to re-send are retried: any +GET+
@@ -69,6 +75,7 @@ module BaseCradle
69
75
  @timeout = timeout
70
76
  @max_retries = max_retries
71
77
  @start_here = nil
78
+ @session = nil
72
79
  @timelines = TimelinesResource.new(self)
73
80
  @messages = MessagesResource.new(self)
74
81
  @assets = AssetsResource.new(self)
@@ -94,7 +101,9 @@ module BaseCradle
94
101
  # Mint a fresh token via POST /session and return an authenticated client.
95
102
  #
96
103
  # The minted token is on the returned client as +#token+ — save it; it is never
97
- # retrievable again. +name+ is an optional label to tell credentials apart later.
104
+ # retrievable again. The credential itself is on +#session+ (so +bc.session.uuid+ is
105
+ # what revokes it later), and +#start_here+ points at the Dashboard. +name+ is an
106
+ # optional label to tell credentials apart later.
98
107
  def self.login(email_address:, password:, name: nil, base_url: DEFAULT_BASE_URL,
99
108
  timeout: DEFAULT_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES)
100
109
  payload = { "email_address" => email_address, "password" => password }
@@ -111,7 +120,7 @@ module BaseCradle
111
120
 
112
121
  body = JSON.parse(response.body)
113
122
  client = new(body["token"], base_url: base_url, timeout: timeout, max_retries: max_retries)
114
- client.instance_variable_set(:@start_here, body["start_here"])
123
+ client.send(:minted, body)
115
124
  client
116
125
  end
117
126
 
@@ -138,6 +147,34 @@ module BaseCradle
138
147
  nil
139
148
  end
140
149
 
150
+ # Change your own password (PATCH /users/password).
151
+ #
152
+ # Self-credential management, like +sessions+ and +sign_out+: a peer rotates its own
153
+ # password with no human at a browser. +current_password+ proves it is you, and
154
+ # +password_confirmation+ must match +password+ — the API rejects a mismatch rather
155
+ # than guessing which one you meant.
156
+ #
157
+ # A password change is *not* a sign-out: every session stays valid, web and API
158
+ # tokens alike, this client's included. Revoke separately if a credential is suspect
159
+ # (+session.revoke+, or +bc.sessions.revoke_all+ for all of them). It is never
160
+ # auto-retried either — an unkeyed write, so +max_retries+ leaves it alone.
161
+ #
162
+ # Raises +CurrentPasswordIncorrectError+ when the current password is wrong,
163
+ # +PasswordConfirmationMismatchError+ when the confirmation differs, and a
164
+ # +ValidationError+ carrying the model's +errors+ when the new password is too weak
165
+ # (10+ characters, mixed case, a number or symbol). The first two *are*
166
+ # +ValidationError+s (they subclass it) and carry no +errors+, so rescue them first
167
+ # if you want to tell them apart.
168
+ #
169
+ # Returns +nil+ (the API replies 204 No Content).
170
+ def change_password(current_password:, password:, password_confirmation:)
171
+ request("PATCH", "/users/password",
172
+ json: { "current_password" => current_password,
173
+ "password" => password,
174
+ "password_confirmation" => password_confirmation })
175
+ nil
176
+ end
177
+
141
178
  # Make an authenticated API request and return the parsed response body.
142
179
  #
143
180
  # Returns the parsed JSON, or +nil+ for 204 / an empty body. Raises a typed
@@ -189,6 +226,13 @@ module BaseCradle
189
226
 
190
227
  private
191
228
 
229
+ # Record what the mint response said about the credential just issued. Private: only
230
+ # +.login+ calls it, on a client it has just built.
231
+ def minted(body)
232
+ @start_here = body["start_here"]
233
+ @session = Session.new(body["session"], client: self) if body["session"]
234
+ end
235
+
192
236
  # Send the request, retrying on a lost connection up to +@max_retries+ times when the
193
237
  # request is safe to re-send. Everything else — the send itself, error mapping — is
194
238
  # unchanged from a single call.
@@ -12,9 +12,14 @@ module BaseCradle
12
12
 
13
13
  # The envelope shape every timeline item shares. +timeline+ is in reference form (just
14
14
  # a uuid) — dereference it with bc.timelines.get(item.timeline.uuid) when you need it.
15
+ #
16
+ # +created_at+ is when the record was made; +updated_at+ moves whenever it changes (a
17
+ # task's status, an endpoint's description or ingest URL), so you can tell a refreshed
18
+ # record from a stale one without diffing it.
15
19
  class Item < ApiObject
16
20
  attribute :type
17
21
  attribute :created_at
22
+ attribute :updated_at
18
23
  attribute :user, wrap: User
19
24
  attribute :timeline, wrap: Reference
20
25
  end
@@ -9,13 +9,22 @@ module BaseCradle
9
9
  # One item on a timeline — a message, asset, webhook event, or task. +type+ says which;
10
10
  # +content+ is the item itself, wire-exact; +user+ is the author.
11
11
  #
12
- # A +webhook_event+ item has no author — it was posted by an external sender, not a peer
13
- # — so the platform omits +user+ there (core #585) and reading it raises
14
- # +MissingFieldError+. Branch on +type+ when you walk a mixed page of items.
12
+ # An inline item is the record's own standalone form, with one difference: +created_at+
13
+ # is the *item's* — when the record landed on the timeline (for a task, its activation;
14
+ # its own page reports when it was scheduled) — while +updated_at+ is the record's.
15
+ #
16
+ # Two fields are type-specific, so branch on +type+ when you walk a mixed page: a
17
+ # +webhook_event+ item has no author — it was posted by an external sender, not a peer —
18
+ # and it alone carries +webhook_endpoint+, the endpoint it arrived on, embedded in full.
19
+ # Reading either where it does not belong raises +MissingFieldError+ rather than
20
+ # inventing a value.
15
21
  class TimelineItem < ApiObject
16
22
  attribute :type
17
23
  attribute :created_at
24
+ attribute :updated_at
18
25
  attribute :user, wrap: User
26
+ attribute :timeline, wrap: Reference
27
+ attribute :webhook_endpoint, wrap: WebhookEndpoint # webhook_event items only
19
28
  attribute :content # shape depends on type — read it wire-exact
20
29
  end
21
30
 
@@ -37,12 +46,11 @@ module BaseCradle
37
46
 
38
47
  # The emergency stop: freeze the timeline's content, permanently. Any viewer can lock;
39
48
  # it is idempotent and one-way (unlocking is an out-of-band admin action).
49
+ #
50
+ # Live object: the API returns the whole locked timeline and this object adopts it, so
51
+ # every field — +locked+, +updated_at+, the roster — is the platform's current answer.
40
52
  def lock
41
- response = require_client.request("POST", "/timelines/#{uuid}/lock")
42
- # The response is moving from a bare {uuid, locked} to the timeline envelope
43
- # (core #585); read the confirmed state off whichever shape arrived.
44
- to_h["locked"] = (response["timeline"] || response)["locked"]
45
- self
53
+ adopt(require_client.request("POST", "/timelines/#{uuid}/lock"))
46
54
  end
47
55
 
48
56
  # Permanently delete this timeline and everything on it — messages, assets, tasks,
@@ -66,9 +74,7 @@ module BaseCradle
66
74
  response = conn.request(
67
75
  "POST", "/timelines/#{uuid}/participations", json: { "user_id" => BaseCradle.uuid_of(user) }
68
76
  )
69
- # The response is moving from a bare nested-actor user to the {"user" => ...}
70
- # envelope (core #585); take the added user from whichever shape arrived.
71
- data = response["user"] || response
77
+ data = response.fetch("user")
72
78
  added = User.new(data, client: conn)
73
79
  roster = (to_h["participants"] ||= [])
74
80
  roster << data unless roster.any? { |p| p["uuid"] == added.uuid }
@@ -109,5 +115,18 @@ module BaseCradle
109
115
  def webhook_events
110
116
  TimelineWebhookEvents.new(require_client, uuid)
111
117
  end
118
+
119
+ private
120
+
121
+ # Live-object update: the API returned the complete timeline, so this object points at
122
+ # it from here on. Inline +items+ we already hold are carried across as a *fallback*,
123
+ # never an override: only the two-key timeline envelope carries items, so the subject
124
+ # form a verb returns has none — and a verb that freezes content has not changed them.
125
+ def adopt(response)
126
+ updated = response.fetch("timeline")
127
+ updated = { "items" => to_h["items"] }.merge(updated) if to_h.key?("items")
128
+ @data = updated
129
+ self
130
+ end
112
131
  end
113
132
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BaseCradle
4
- VERSION = "0.6.1"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "api_object"
4
4
  require_relative "items"
5
+ require_relative "user"
5
6
 
6
7
  module BaseCradle
7
8
  # --- models ---------------------------------------------------------------------------
@@ -22,12 +23,18 @@ module BaseCradle
22
23
  attribute :verification, wrap: WebhookVerification
23
24
  end
24
25
 
25
- # An inbound webhook URL on a timeline. Endpoints belong to the timeline, not a user,
26
- # so there is no +user+ block. Verbs update this object from the full endpoint the API
27
- # returns (live objects).
26
+ # An inbound webhook URL on a timeline. An endpoint is authored: +user+ is the peer who
27
+ # created it (nested-actor form), and every event delivered to it inherits that author.
28
+ # Verbs update this object from the full endpoint the API returns (live objects).
29
+ #
30
+ # The endpoint's identity is +content.uuid+ — the wire carries no top-level +uuid+, and
31
+ # the SDK does not invent one. +BaseCradle.uuid_of(endpoint)+ yields it too, which is
32
+ # what +bc.webhook_events.filter(endpoint:)+ uses.
28
33
  class WebhookEndpoint < ApiObject
29
34
  attribute :type
30
35
  attribute :created_at
36
+ attribute :updated_at
37
+ attribute :user, wrap: User # the endpoint's author
31
38
  attribute :timeline, wrap: Reference
32
39
  attribute :content, wrap: WebhookEndpointContent
33
40
 
@@ -65,28 +72,161 @@ module BaseCradle
65
72
  end
66
73
  end
67
74
 
68
- # One inbound delivery: what was sent, and on which (possibly retired) ingest URL.
75
+ # One delivery's request headers: the wire's own spelling, looked up case-insensitively.
76
+ #
77
+ # A +Hash+ of exactly what the wire carried — one pair per header, +Content-Type+ and
78
+ # +Content-Length+ included — so iterating, +keys+, +to_h+, +to_json+ and +==+ all read
79
+ # the platform's own spelling and nothing is renamed. Only *lookup* folds case, because
80
+ # header names are case-insensitive by RFC and the platform does not preserve the
81
+ # sender's casing: it stores names canonicalized to Title-Case per segment
82
+ # (+X-Github-Delivery+, not +X-GitHub-Delivery+). So a vendor's published spelling finds
83
+ # the header it names, and so does any other casing of it:
84
+ #
85
+ # event.content.headers["X-GitHub-Delivery"] # GitHub's own published spelling
86
+ # event.content.headers["x-github-delivery"] # the lowercase form
87
+ # event.content.headers["X-Github-Delivery"] # what the wire actually carried
88
+ #
89
+ # Every read that takes a header name folds: +fetch+, +dig+, +values_at+, +fetch_values+,
90
+ # +to_proc+ and +key?+ (with +has_key?+, +include?+ and +member?+). A header that was
91
+ # genuinely not delivered is *absent*, never +nil+ — +[]+ and a fallback-less +fetch+ or
92
+ # +fetch_values+ raise +KeyError+ naming the headers that did arrive, while +fetch+ with
93
+ # a fallback, +dig+ and +values_at+ answer for it the way +Hash+ does.
94
+ #
95
+ # The methods that *reshape* the hash rather than read one name — +slice+, +except+,
96
+ # +select+, +transform_values+ — and the mutators are left exactly as +Hash+ defines
97
+ # them: case-sensitive, on the platform's own spelling. This is a read of one delivery
98
+ # that already happened. Converting away from this type gives up the folding too
99
+ # (+to_h+, <tt>{**headers}</tt>, and anything else that hands back a plain +Hash+);
100
+ # +merge+ and +dup+ keep it.
101
+ class WebhookEventHeaders < Hash
102
+ # Hash's own exact-match membership, kept under a private name before the case-folding
103
+ # +key?+ below takes the name: a caller who wrote the wire's own spelling is answered
104
+ # without a scan, and it is the tiebreak if a derived copy holds two casings of one
105
+ # name.
106
+ alias_method :exact_key?, :key?
107
+ private :exact_key?
108
+
109
+ NO_FALLBACK = Object.new.freeze
110
+ private_constant :NO_FALLBACK
111
+
112
+ # +client:+ is unused — headers carry no verbs — but every wrapped model is built with
113
+ # it (see ApiObject.attribute's +wrap:+), so it is accepted.
114
+ def initialize(data, client: nil)
115
+ super()
116
+ update(data)
117
+ end
118
+
119
+ # The header's value, matched case-insensitively. Raises +KeyError+ when no casing of
120
+ # the name was delivered.
121
+ def [](name)
122
+ wire = wire_name(name)
123
+ wire.nil? ? raise_missing(name) : super(wire)
124
+ end
125
+
126
+ # Hash-like: the value, or the fallback — a default argument, or a block, which is
127
+ # handed the name as written — when the header was not delivered. With no fallback it
128
+ # behaves like +[]+.
129
+ def fetch(name, fallback = NO_FALLBACK, &block)
130
+ wire = wire_name(name)
131
+ return super(wire) if wire
132
+ return yield(name) if block
133
+ return fallback unless fallback.equal?(NO_FALLBACK)
134
+
135
+ raise_missing(name)
136
+ end
137
+
138
+ # Did this delivery carry the header, under any casing?
139
+ def key?(name)
140
+ !wire_name(name).nil?
141
+ end
142
+ alias_method :has_key?, :key?
143
+ alias_method :include?, :key?
144
+ alias_method :member?, :key?
145
+
146
+ # Ruby's nil-returning read, case folded: the value, or +nil+ for a header that was
147
+ # not delivered.
148
+ def dig(name, *rest)
149
+ wire = wire_name(name)
150
+ wire.nil? ? nil : super(wire, *rest)
151
+ end
152
+
153
+ # Several headers at once, folded the same way — +nil+ for one that was not delivered,
154
+ # as +Hash+ answers for a key it has no value for.
155
+ def values_at(*names)
156
+ names.map { |name| dig(name) }
157
+ end
158
+
159
+ # As +values_at+, but a header that was not delivered raises, exactly as +fetch+ does.
160
+ def fetch_values(*names, &block)
161
+ names.map { |name| block ? fetch(name, &block) : fetch(name) }
162
+ end
163
+
164
+ # +&headers+ — a header name to its value, folded, +nil+ for one not delivered.
165
+ def to_proc
166
+ ->(name) { dig(name) }
167
+ end
168
+
169
+ private
170
+
171
+ # The wire's own spelling of +name+, or nil if no casing of it was delivered. Anything
172
+ # but a String is not a header name, so it is never matched rather than being coerced
173
+ # into one: +headers[:host]+ finds nothing, and so misses like any other absent name.
174
+ # Keys are type-checked too — +merge+ hands back one of these, so a caller's derived
175
+ # copy can hold a key that never came off the wire.
176
+ def wire_name(name)
177
+ return nil unless name.is_a?(String)
178
+ return name if exact_key?(name)
179
+
180
+ each_key { |wire| return wire if wire.is_a?(String) && wire.casecmp?(name) }
181
+ nil
182
+ end
183
+
184
+ def raise_missing(name)
185
+ raise KeyError.new(
186
+ "No #{name.inspect} header on this delivery. Header names are matched " \
187
+ "case-insensitively, so no casing of it was delivered either. " \
188
+ "Headers present: #{keys.sort_by(&:to_s).inspect}",
189
+ receiver: self, key: name
190
+ )
191
+ end
192
+ end
193
+
194
+ # One inbound delivery: what was sent, and the two facts about the endpoint as it was at
195
+ # the moment of receipt. Everything in the event's embedded endpoint is *current*; these
196
+ # two are historical, and they are the only ones.
197
+ #
198
+ # +headers+ is the delivery's request headers as a +WebhookEventHeaders+ — the wire's
199
+ # own pairs, looked up case-insensitively. Reached instead through a +webhook_event+ row
200
+ # of +timeline.items+, +content+ is the generic wire-exact Hash — a timeline item's
201
+ # content is a union of four record types, so it is not a +WebhookEventContent+ — and
202
+ # +item.content["headers"]+ is the plain Hash the API returned. +bc.webhook_events+ and
203
+ # +timeline.webhook_events+ give the case-folding one.
69
204
  class WebhookEventContent < ApiObject
70
205
  attribute :uuid
71
206
  attribute :content_type
72
- attribute :headers
207
+ attribute :headers, wrap: WebhookEventHeaders
73
208
  attribute :payload # the raw request body, exactly as delivered
209
+ # The ingest token this delivery arrived on. Because the token rotates, comparing it
210
+ # to the end of the embedded endpoint's current +ingest_url+ tells you whether the
211
+ # endpoint has rotated since.
74
212
  attribute :ingest_token_at_receipt
213
+ # Whether this delivery's signature was verified when it arrived — false on an
214
+ # endpoint that had no signing secret at the time.
215
+ attribute :verified_at_receipt
75
216
  end
76
217
 
77
- # One inbound delivery to a webhook endpoint. Read-only — produced by external senders.
218
+ # One inbound delivery to a webhook endpoint. Read-only — produced by external senders,
219
+ # so an event has no author.
78
220
  class WebhookEvent < ApiObject
79
221
  attribute :type
80
222
  attribute :created_at
223
+ attribute :updated_at
81
224
  attribute :timeline, wrap: Reference
82
- # The event's direct container. The platform is moving this key from a bare reference
83
- # to the endpoint's full subject form (core #585), so the wrapper is chosen from the
84
- # payload: a full endpoint (it carries +content+) wraps as a WebhookEndpoint — its
85
- # uuid is +webhook_endpoint.content.uuid+, and its verbs (disable / enable / rotate)
86
- # are reachable — while a reference still wraps as a Reference, whose +uuid+ is the
87
- # endpoint's. Read the uuid off whichever you got with +BaseCradle.uuid_of+.
88
- attribute :webhook_endpoint,
89
- wrap: ->(data) { data.key?("content") ? WebhookEndpoint : Reference }
225
+ # The endpoint this arrived on, embedded in full (its own subject form) rather than by
226
+ # reference — so its current state reads without a second request, and its verbs
227
+ # (disable / enable / rotate) are reachable straight off the event. Its uuid is
228
+ # +event.webhook_endpoint.content.uuid+, or +BaseCradle.uuid_of(...)+.
229
+ attribute :webhook_endpoint, wrap: WebhookEndpoint
90
230
  attribute :content, wrap: WebhookEventContent
91
231
  end
92
232
 
@@ -127,8 +267,9 @@ module BaseCradle
127
267
  # Create an inbound webhook endpoint on this timeline (viewer; the timeline unlocked).
128
268
  #
129
269
  # +idempotency_key+ (optional, a UUID recommended) makes the create safe to retry: the
130
- # platform stores at most one endpoint per key (scoped per timeline — endpoints have no
131
- # author), so a resend returns the original endpoint. See +BaseCradle::Client#max_retries+.
270
+ # platform stores at most one endpoint per key — scoped per timeline and author, like
271
+ # the other three creates — so a resend returns the original endpoint. See
272
+ # +BaseCradle::Client#max_retries+.
132
273
  def create(description:, idempotency_key: nil)
133
274
  response = @client.request("POST", "/timelines/#{@timeline_uuid}/webhook_endpoints",
134
275
  json: { "webhook_endpoint" => { "description" => description } },
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.6.1
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Drawk Kwast