basecamp-sdk 0.14.0 → 0.16.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.
@@ -2,16 +2,53 @@
2
2
 
3
3
  require "faraday"
4
4
  require "json"
5
+ require "timeout"
5
6
  require "uri"
6
7
 
7
8
  module Basecamp
8
9
  module Oauth
9
10
  # Handles OAuth 2 token exchange and refresh operations.
11
+ #
12
+ # Both operations POST credentials — the authorization code and client
13
+ # secret, or the refresh token — to a token endpoint the caller names,
14
+ # which may be one that discovery's metadata chose. The POST therefore
15
+ # rides the same hardened transport discipline as the device flow
16
+ # (SPEC §16 "Token-Endpoint Transport Policy"): redirects are refused
17
+ # rather than followed, the whole request is wall-clock bounded, and the
18
+ # body reads under the shared streaming cap.
10
19
  class Exchange
11
- # @param http_client [Faraday::Connection, nil] HTTP client (uses default if nil)
12
- # @param timeout [Integer] Request timeout in seconds (default: 30)
13
- def initialize(http_client: nil, timeout: 30)
14
- @http_client = http_client || build_default_client(timeout)
20
+ # The redirect statuses a token endpoint response is refused for
21
+ # (SPEC §16 "Token-Endpoint Transport Policy") the same set the signed
22
+ # download hop refuses (SPEC §14). 304 is deliberately absent: it is a
23
+ # cache validator, not a redirect-with-Location, and stays on the
24
+ # generic non-success path.
25
+ REDIRECT_STATUSES = [ 301, 302, 303, 307, 308 ].freeze
26
+
27
+ # Default per-request timeout in seconds — the shared credential-POST
28
+ # default every SDK's token and device POSTs converge on (SPEC §16).
29
+ DEFAULT_TIMEOUT = 30
30
+
31
+ # Cap on a token response body (1 MiB), matching the device flow's and
32
+ # the other SDKs' token-response bound.
33
+ MAX_BODY_BYTES = 1 * 1024 * 1024
34
+
35
+ # @param http_client [Faraday::Connection, nil] HTTP client. Nil selects
36
+ # the headers-first default transport ({Fetcher.stream_http}); an
37
+ # injected connection is refused unless its stack is verifiably
38
+ # redirect-free (adapter-only), and keeps the injected-client fidelity
39
+ # tier: status classification only after the (bounded) read completes,
40
+ # deadline enforced wall-clock around the call.
41
+ # @param timeout [Numeric] Request timeout in seconds (default: 30).
42
+ # Invalid values and values beyond the shared 3600 s ceiling fall back
43
+ # to the default rather than disabling the bound.
44
+ def initialize(http_client: nil, timeout: DEFAULT_TIMEOUT)
45
+ # An injected connection is the caller's transport, but redirect
46
+ # suppression is not negotiable on a credential POST: refuse a stack
47
+ # that could follow (or rewrite) before any request is issued — the
48
+ # same guard discovery, resource, and the device flow apply.
49
+ Fetcher.ensure_redirects_suppressed!(http_client) if http_client
50
+ @http_client = http_client
51
+ @timeout = Fetcher.normalize_timeout(timeout, default: DEFAULT_TIMEOUT)
15
52
  end
16
53
 
17
54
  # Exchanges an authorization code for access and refresh tokens.
@@ -80,14 +117,6 @@ module Basecamp
80
117
 
81
118
  private
82
119
 
83
- def build_default_client(timeout)
84
- Faraday.new do |conn|
85
- conn.options.timeout = timeout
86
- conn.options.open_timeout = timeout
87
- conn.adapter Faraday.default_adapter
88
- end
89
- end
90
-
91
120
  def validate_exchange_request!(request)
92
121
  raise OauthError.new("validation", "Token endpoint is required") if request.token_endpoint.to_s.empty?
93
122
  raise OauthError.new("validation", "Authorization code is required") if request.code.to_s.empty?
@@ -145,30 +174,128 @@ module Basecamp
145
174
  def do_token_request(token_endpoint, params)
146
175
  Basecamp::Security.require_https_unless_localhost!(token_endpoint, "token endpoint")
147
176
 
148
- response = @http_client.post(token_endpoint) do |req|
149
- req.headers["Content-Type"] = "application/x-www-form-urlencoded"
150
- req.headers["Accept"] = "application/json"
151
- req.body = URI.encode_www_form(params)
177
+ status, body = post_form(
178
+ token_endpoint, params,
179
+ skip_status: ->(s) { REDIRECT_STATUSES.include?(s) }
180
+ )
181
+
182
+ # A refused redirect is a typed verdict classified by status alone —
183
+ # its body (skipped above) is never a token, and the credential POST
184
+ # is never re-issued toward Location (SPEC §16).
185
+ if REDIRECT_STATUSES.include?(status)
186
+ raise OauthError.new(
187
+ "api_error",
188
+ "redirect #{status} on the token endpoint is not followed",
189
+ http_status: status
190
+ )
152
191
  end
153
192
 
154
- parse_token_response(response)
193
+ parse_token_response(status, body)
155
194
  rescue Faraday::TimeoutError
156
195
  raise OauthError.new("network", "Token request timed out", retryable: true)
157
196
  rescue Faraday::Error => e
158
197
  raise OauthError.new("network", "Token request failed: #{e.message}", retryable: true)
159
198
  end
160
199
 
161
- def parse_token_response(response)
162
- Basecamp::Security.check_body_size!(response.body, Basecamp::Security::MAX_ERROR_BODY_BYTES, "Token")
200
+ # POSTs the token form and returns +[status, body]+, reading under the
201
+ # same bounded/streaming cap as discovery and the device flow.
202
+ #
203
+ # With no injected client the POST runs on the headers-first
204
+ # {Fetcher.stream_http} primitive: +skip_status+ classifies a redirect
205
+ # by status at HEADER time (its body is never read, even one that
206
+ # stalls forever), redirects are structurally never followed, and a
207
+ # watchdog bounds the whole request — a stalled or byte-dripped header
208
+ # phase included — at the timeout. An INJECTED Faraday connection keeps
209
+ # the Faraday path below.
210
+ def post_form(url, params, skip_status:)
211
+ if @http_client.nil?
212
+ Fetcher.stream_http(
213
+ :post, url,
214
+ headers: { "Content-Type" => "application/x-www-form-urlencoded", "Accept" => "application/json" },
215
+ form: params, timeout: @timeout, max_body_bytes: MAX_BODY_BYTES, skip_status: skip_status
216
+ )
217
+ else
218
+ post_form_injected(url, params, skip_status)
219
+ end
220
+ rescue Fetcher::SkipBody => e
221
+ # The body was intentionally not drained (a redirect's body is never a
222
+ # token) — classify by status upstream.
223
+ [ e.status, "" ]
224
+ rescue Fetcher::BodyTooLarge
225
+ raise OauthError.new("api_error", "Token response exceeds size cap")
226
+ rescue Fetcher::ReadDeadlineExceeded
227
+ # A slow-drip read is a transport timeout, not an api_error — surface
228
+ # as the Faraday timeout the caller's rescue classifies.
229
+ raise Faraday::TimeoutError, "Token request read exceeded the timeout deadline"
230
+ end
231
+
232
+ # Injected-client (Faraday) lane — the injected-client fidelity tier
233
+ # (SPEC §16): the same invariants as the default transport (suppressed
234
+ # redirects, bounded body, whole-request wall clock), with buffered
235
+ # classification. +req.options.timeout+ below bounds only each socket
236
+ # read and resets on every +on_data+ chunk, so a slow-drip peer could
237
+ # otherwise hold the credential POST open past the timeout while
238
+ # staying under the cap — the monotonic deadline bounds the WHOLE
239
+ # request, and +Timeout.timeout+ enforces it through a stalled or
240
+ # dripped HEADER phase, where +on_data+ (a body callback) never runs.
241
+ def post_form_injected(url, params, skip_status)
242
+ deadline = Fetcher.monotonic_now + @timeout
243
+ chunks, on_data = Fetcher.bounded_reader(MAX_BODY_BYTES, deadline: deadline, skip_status: skip_status)
244
+ # The window is the REMAINING budget, not a fresh timeout: time spent
245
+ # before dispatch already counts against the deadline, so the request
246
+ # can never run past it.
247
+ remaining = deadline - Fetcher.monotonic_now
248
+ raise Faraday::TimeoutError, "request budget exhausted before dispatch" if remaining <= 0
249
+
250
+ response = Timeout.timeout(remaining, Faraday::TimeoutError) do
251
+ @http_client.post(url) do |req|
252
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
253
+ req.headers["Accept"] = "application/json"
254
+ req.body = URI.encode_www_form(params)
255
+ req.options.timeout = @timeout
256
+ req.options.open_timeout = @timeout
257
+ req.options.on_data = on_data
258
+ end
259
+ end
260
+
261
+ # Status-first backstop on the completed response: the +on_data+
262
+ # SkipBody fast-path only fires when the adapter streams AND passes
263
+ # +env+ (Faraday >= 2.5). A buffered adapter that ignores +on_data+,
264
+ # an older Faraday (2.0–2.4) that omits +env+, or a header-only
265
+ # response reaches here with the redirect body un-skipped — re-apply
266
+ # +skip_status+ to the final status so a redirect is classified by
267
+ # status for every client shape, never buffered into a size-cap error.
268
+ # A definitive completed status outranks the deadline re-check below;
269
+ # everything else completing past the deadline is refused as the same
270
+ # transport-shaped timeout (Timeout.timeout's interrupt can land late).
271
+ if skip_status.call(response.status)
272
+ [ response.status, "" ]
273
+ elsif Fetcher.monotonic_now > deadline
274
+ raise Faraday::TimeoutError, "response completed after the deadline"
275
+ else
276
+ body =
277
+ if chunks.empty?
278
+ raw = response.body.to_s
279
+ raise Fetcher::BodyTooLarge if raw.bytesize > MAX_BODY_BYTES
280
+
281
+ raw
282
+ else
283
+ chunks.join
284
+ end
285
+
286
+ [ response.status, body.dup.force_encoding(Encoding::UTF_8) ]
287
+ end
288
+ end
163
289
 
164
- data = JSON.parse(response.body)
290
+ def parse_token_response(status, body)
291
+ data = JSON.parse(body)
165
292
 
166
- handle_error_response(response.status, data) unless response.success?
293
+ handle_error_response(status, data) unless (200..299).cover?(status)
167
294
 
168
295
  unless data["access_token"].is_a?(String) && !data["access_token"].empty?
169
296
  raise OauthError.new(
170
297
  "api_error", "Token response missing or non-string access_token",
171
- http_status: response.status
298
+ http_status: status
172
299
  )
173
300
  end
174
301
 
@@ -179,7 +306,7 @@ module Basecamp
179
306
  raise OauthError.new(
180
307
  "api_error",
181
308
  "Token response resource must be a non-empty string when present",
182
- http_status: response.status
309
+ http_status: status
183
310
  )
184
311
  end
185
312
 
@@ -191,7 +318,7 @@ module Basecamp
191
318
  raise OauthError.new(
192
319
  "api_error",
193
320
  "Token response token_type must be a non-empty string when present",
194
- http_status: response.status
321
+ http_status: status
195
322
  )
196
323
  end
197
324
 
@@ -213,7 +340,7 @@ module Basecamp
213
340
  raise OauthError.new(
214
341
  "api_error",
215
342
  "Failed to parse token response",
216
- http_status: response.status
343
+ http_status: status
217
344
  ), cause: nil
218
345
  end
219
346
 
@@ -239,7 +239,7 @@ module Basecamp
239
239
 
240
240
  raise OauthError.new(
241
241
  "validation",
242
- "Injected OAuth discovery client must carry only an adapter (no middleware); " \
242
+ "Injected OAuth client must carry only an adapter (no middleware); " \
243
243
  "found #{offending.klass.name}. Redirects are suppressed for SSRF safety, so a " \
244
244
  "connection whose middleware stack cannot be verified redirect-free is refused"
245
245
  )
@@ -16,6 +16,22 @@ module Basecamp
16
16
  # Token endpoint for Basecamp OAuth
17
17
  TOKEN_URL = "https://launchpad.37signals.com/authorization/token"
18
18
 
19
+ # The redirect statuses a token endpoint response is refused for
20
+ # (SPEC §16 "Token-Endpoint Transport Policy") — the refresh POST carries
21
+ # the refresh token and client secret, and a redirect must surface as a
22
+ # typed fault rather than re-issue those credentials toward Location.
23
+ # 304 stays on the generic non-success path (a cache validator, not a
24
+ # redirect-with-Location).
25
+ REDIRECT_STATUSES = [ 301, 302, 303, 307, 308 ].freeze
26
+
27
+ # Whole-request bound in seconds for the refresh POST — the shared
28
+ # credential-POST default (SPEC §16). Enforced as socket timeouts AND a
29
+ # monotonic wall-clock deadline by the transport below.
30
+ REFRESH_TIMEOUT = 30
31
+
32
+ # Cap on a refresh response body (1 MiB), matching the exchange path.
33
+ MAX_RESPONSE_BYTES = 1 * 1024 * 1024
34
+
19
35
  # @return [String, nil] the current refresh token
20
36
  attr_reader :refresh_token
21
37
 
@@ -77,30 +93,51 @@ module Basecamp
77
93
  perform_refresh if expired? && refreshable?
78
94
  end
79
95
 
96
+ # The refresh POST runs on the headers-first {Oauth::Fetcher.stream_http}
97
+ # primitive — the same transport as the exchange and device paths — so it
98
+ # gets the full SPEC §16 discipline rather than a bare Faraday.post:
99
+ # redirects structurally never followed and classified at header time,
100
+ # socket timeouts plus a monotonic whole-request watchdog (a slow-drip
101
+ # peer cannot hold the refresh open past REFRESH_TIMEOUT), and a bounded
102
+ # streaming body read.
80
103
  def perform_refresh
81
104
  require "faraday"
82
105
  require "json"
83
- require "uri"
84
-
85
- response = Faraday.post(TOKEN_URL) do |req|
86
- req.headers["Content-Type"] = "application/x-www-form-urlencoded"
87
- req.body = URI.encode_www_form(
88
- type: "refresh",
89
- refresh_token: @refresh_token,
90
- client_id: @client_id,
91
- client_secret: @client_secret
92
- )
93
- end
94
106
 
95
- raise AuthError.new("Token refresh failed: #{response.status}") unless response.success?
107
+ status, body = Oauth::Fetcher.stream_http(
108
+ :post, TOKEN_URL,
109
+ headers: { "Content-Type" => "application/x-www-form-urlencoded" },
110
+ form: {
111
+ "type" => "refresh",
112
+ "refresh_token" => @refresh_token,
113
+ "client_id" => @client_id,
114
+ "client_secret" => @client_secret
115
+ },
116
+ timeout: REFRESH_TIMEOUT,
117
+ max_body_bytes: MAX_RESPONSE_BYTES,
118
+ skip_status: ->(s) { REDIRECT_STATUSES.include?(s) }
119
+ )
120
+
121
+ # A refused redirect is a typed api fault carrying the real status —
122
+ # not the generic AuthError below, which would imply the credentials
123
+ # were judged and rejected when no such judgement happened.
124
+ if REDIRECT_STATUSES.include?(status)
125
+ raise ApiError.new("redirect #{status} on the token endpoint is not followed", http_status: status)
126
+ end
127
+ raise AuthError.new("Token refresh failed: #{status}") unless (200..299).cover?(status)
96
128
 
97
- data = JSON.parse(response.body)
129
+ data = JSON.parse(body)
98
130
  @access_token = data["access_token"]
99
131
  @expires_at = Time.now + data["expires_in"].to_i if data["expires_in"]
100
132
 
101
133
  @on_refresh&.call(@access_token, @refresh_token, @expires_at)
102
134
 
103
135
  true
136
+ rescue Oauth::Fetcher::BodyTooLarge
137
+ raise ApiError.new("Token refresh response exceeds size cap")
138
+ rescue Oauth::Fetcher::ReadDeadlineExceeded => e
139
+ # A slow-drip read past the deadline is a transport timeout.
140
+ raise NetworkError.new("Token refresh network error", cause: e)
104
141
  rescue Faraday::Error => e
105
142
  raise NetworkError.new("Token refresh network error", cause: e)
106
143
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Raised when a template copy requires confirmation before granting project access.
5
+ class PeopleConfirmationRequiredError < ValidationError
6
+ # @return [Array<Basecamp::Types::TemplateLibraryConfirmationPerson>]
7
+ attr_reader :people
8
+
9
+ def initialize(message, people:, hint: nil, http_status: 422, field_errors: nil)
10
+ super(message, hint: hint, http_status: http_status, field_errors: field_errors)
11
+ @people = people
12
+ end
13
+ end
14
+ end
@@ -3,8 +3,10 @@
3
3
  module Basecamp
4
4
  # Raised when rate limited (429).
5
5
  class RateLimitError < Error
6
- def initialize(retry_after: nil, cause: nil)
7
- hint = retry_after ? "Try again in #{retry_after} seconds" : "Please slow down requests"
6
+ def initialize(retry_after: nil, hint: nil, cause: nil)
7
+ # A concrete Retry-After beats a body-derived hint; the class default
8
+ # fills in when neither is present.
9
+ hint = retry_after ? "Try again in #{retry_after} seconds" : (hint || "Please slow down requests")
8
10
  super(
9
11
  code: ErrorCode::RATE_LIMIT,
10
12
  message: "Rate limit exceeded",
@@ -32,6 +32,26 @@ module Basecamp
32
32
  raise UsageError.new("Invalid #{label}: #{url}")
33
33
  end
34
34
 
35
+ # Renders a URL for hooks as origin+path only (SPEC section 9): no
36
+ # userinfo, query or fragment. The download flow's hop-1 URL can carry a
37
+ # signed credential in its query; the wire request keeps the whole URL. A
38
+ # URL with no complete origin renders as the fixed token, never as any of
39
+ # its own text.
40
+ # @param url [String]
41
+ # @return [String]
42
+ def self.display_url(url)
43
+ uri = URI.parse(url)
44
+ return "unparsable" if uri.scheme.nil? || uri.host.nil? || uri.host.empty?
45
+
46
+ uri.password = nil
47
+ uri.user = nil
48
+ uri.query = nil
49
+ uri.fragment = nil
50
+ uri.to_s
51
+ rescue URI::Error
52
+ "unparsable"
53
+ end
54
+
35
55
  def self.same_origin?(a, b)
36
56
  ua = URI.parse(a)
37
57
  ub = URI.parse(b)
@@ -39,13 +59,19 @@ module Basecamp
39
59
 
40
60
  ua.scheme.downcase == ub.scheme.downcase &&
41
61
  normalize_host(ua) == normalize_host(ub)
42
- rescue URI::InvalidURIError
62
+ rescue URI::Error
63
+ # URI::Error, not just InvalidURIError: URI.parse("mailto:") raises
64
+ # URI::InvalidComponentError (URI::MailTo demands an opaque part), and a
65
+ # scheme-only URL must be refused, not a crash.
43
66
  false
44
67
  end
45
68
 
46
69
  def self.resolve_url(base, target)
47
70
  URI.join(base, target).to_s
48
- rescue URI::InvalidURIError
71
+ rescue URI::Error
72
+ # URI::Error, not just InvalidURIError: URI.join raises
73
+ # URI::InvalidComponentError on a scheme-only target ("mailto:"),
74
+ # reachable from a poisoned Link header or Location redirect.
49
75
  target
50
76
  end
51
77
 
@@ -10,9 +10,11 @@ module Basecamp
10
10
  # document (+app/views/api/authorizations/show.json.jbuilder+), which is not
11
11
  # Launchpad's: it carries +identity.id+ and nothing else of the identity, no
12
12
  # +product+ or +app_href+ on accounts, an RFC 8707 +resource+ indicator
13
- # instead, a top-level +scope+ for BC3-issued tokens, and +expires_at+ as
14
- # integer epoch seconds rather than ISO-8601. Only +identity.id+,
15
- # +accounts[].id+, +accounts[].name+ and +accounts[].href+ are common to both.
13
+ # instead, and a top-level +scope+ for BC3-issued tokens. Only +identity.id+,
14
+ # +accounts[].id+, +accounts[].name+, +accounts[].href+ and +expires_at+ are
15
+ # common to both; +expires_at+ is an ISO-8601 string from either issuer
16
+ # (integer epoch seconds from bc3 before bc3 #12646 — passed through
17
+ # verbatim either way).
16
18
  #
17
19
  # @example Get authorization info
18
20
  # auth = client.authorization.get
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Basecamp
4
- VERSION = "0.14.0"
5
- API_VERSION = "2026-08-05"
4
+ VERSION = "0.16.0"
5
+ API_VERSION = "2026-09-02"
6
6
  end
data/lib/basecamp.rb CHANGED
@@ -117,31 +117,43 @@ module Basecamp
117
117
  # @param retry_after [Integer, nil] Retry-After header value
118
118
  # @return [Error]
119
119
  def self.error_from_response(status, body = nil, retry_after: nil)
120
- message = parse_error_message(body) || "Request failed"
120
+ # SPEC §6 step 3: a body's error_description becomes the hint. Step 5:
121
+ # with no body message, the else arm falls back (via from_status) to the
122
+ # fixed code-bearing phrase, never a reason phrase.
123
+ hint = parse_error_hint(body)
124
+ server_message = parse_error_message(body)
125
+ message = server_message || "Request failed"
121
126
 
122
127
  case status
123
128
  when 400, 422
124
129
  field_errors = parse_field_errors(body)
125
- message = Security.truncate(compose_validation_message(parse_error_message(body), field_errors) || "Request failed")
126
- ValidationError.new(message, http_status: status, field_errors: field_errors)
130
+ message = Security.truncate(compose_validation_message(server_message, field_errors) || "Request failed")
131
+ people = status == 422 ? parse_template_library_confirmation_people(body) : nil
132
+ if people
133
+ PeopleConfirmationRequiredError.new(
134
+ message, people: people, hint: hint, http_status: status, field_errors: field_errors
135
+ )
136
+ else
137
+ ValidationError.new(message, hint: hint, http_status: status, field_errors: field_errors)
138
+ end
127
139
  when 401
128
- AuthError.new(message)
140
+ AuthError.new(message, hint: hint)
129
141
  when 403
130
- ForbiddenError.new(message)
142
+ ForbiddenError.new(message, hint: hint)
131
143
  when 404
132
- NotFoundError.new(message: message)
144
+ NotFoundError.new(message: message, hint: hint)
133
145
  when 429
134
- RateLimitError.new(retry_after: retry_after)
146
+ RateLimitError.new(retry_after: retry_after, hint: hint)
135
147
  when 507
136
148
  # Decided before the 5xx arms: a 507 is an account limit, not a
137
149
  # transient server failure, and no retry can satisfy it.
138
- LimitExceededError.new(Security.truncate(message))
150
+ LimitExceededError.new(Security.truncate(message), hint: hint)
139
151
  when 500
140
- ApiError.new("Server error (500)", http_status: 500, retryable: true)
152
+ ApiError.new("Server error (500)", http_status: 500, retryable: true, hint: hint)
141
153
  when 502, 503, 504
142
- ApiError.new("Gateway error (#{status})", http_status: status, retryable: true)
154
+ ApiError.new("Gateway error (#{status})", http_status: status, retryable: true, hint: hint)
143
155
  else
144
- ApiError.from_status(status, message)
156
+ ApiError.from_status(status, server_message, hint: hint)
145
157
  end
146
158
  end
147
159
 
@@ -180,6 +192,46 @@ module Basecamp
180
192
  nil
181
193
  end
182
194
 
195
+ # Parses the SPEC section 6 step-3 hint from a response body: the
196
+ # "error_description" key, used only when its value is a non-empty String,
197
+ # truncated like the message.
198
+ # @param body [String, nil]
199
+ # @return [String, nil]
200
+ def self.parse_error_hint(body)
201
+ return nil if body.nil? || body.empty?
202
+
203
+ Security.check_body_size!(body, Security::MAX_ERROR_BODY_BYTES, "Error")
204
+
205
+ data = JSON.parse(body)
206
+ hint = data.is_a?(Hash) ? data["error_description"] : nil
207
+ hint.is_a?(String) && !hint.empty? ? Security.truncate(hint) : nil
208
+ rescue JSON::ParserError, ApiError
209
+ nil
210
+ end
211
+
212
+ # Extracts the people whose destination-project access requires confirmation.
213
+ # @param body [String, nil]
214
+ # @return [Array<Basecamp::Types::TemplateLibraryConfirmationPerson>, nil]
215
+ def self.parse_template_library_confirmation_people(body)
216
+ return nil if body.nil? || body.empty?
217
+
218
+ Security.check_body_size!(body, Security::MAX_ERROR_BODY_BYTES, "Error")
219
+ data = JSON.parse(body)
220
+ people = data.is_a?(Hash) ? data["people"] : nil
221
+ return nil unless people.is_a?(Array) && !people.empty?
222
+
223
+ valid = people.all? do |person|
224
+ person.is_a?(Hash) && person["id"].is_a?(Integer) && person["id"].positive? &&
225
+ person["name"].is_a?(String) && !person["name"].empty? &&
226
+ person["avatar_url"].is_a?(String) && !person["avatar_url"].empty?
227
+ end
228
+ return nil unless valid
229
+
230
+ people.map { |person| Types::TemplateLibraryConfirmationPerson.new(person) }
231
+ rescue JSON::ParserError, ApiError
232
+ nil
233
+ end
234
+
183
235
  # Extracts the field-keyed validation errors map from a response body — the
184
236
  # Rails RecordInvalid rendering {"errors" => {"field" => ["msg", ...]}}.
185
237
  # Entries whose value is not an array are skipped, non-string elements are
@@ -73,7 +73,7 @@ class ServiceGenerator
73
73
  },
74
74
  'Automation' => {
75
75
  'Tools' => %w[GetTool UpdateTool DeleteTool CreateTool EnableTool DisableTool RepositionTool],
76
- 'Recordings' => %w[ArchiveRecording UnarchiveRecording TrashRecording ListRecordings],
76
+ 'Recordings' => %w[ArchiveRecording UnarchiveRecording TrashRecording ListRecordings SpotlightRecording UnspotlightRecording],
77
77
  'Webhooks' => %w[ListWebhooks CreateWebhook GetWebhook UpdateWebhook DeleteWebhook],
78
78
  'Events' => %w[ListEvents],
79
79
  'Lineup' => %w[CreateLineupMarker UpdateLineupMarker DeleteLineupMarker],
@@ -81,6 +81,7 @@ class ServiceGenerator
81
81
  'Templates' => %w[
82
82
  ListTemplates CreateTemplate GetTemplate UpdateTemplate
83
83
  DeleteTemplate CreateProjectFromTemplate GetProjectConstruction
84
+ GetTemplateLibrary CreateTemplateLibraryCopy GetTemplateLibraryCopy
84
85
  ],
85
86
  'Checkins' => %w[
86
87
  GetQuestionnaire ListQuestions CreateQuestion GetQuestion
@@ -139,6 +140,8 @@ class ServiceGenerator
139
140
 
140
141
  # Method name overrides
141
142
  METHOD_NAME_OVERRIDES = {
143
+ 'SpotlightRecording' => 'spotlight',
144
+ 'UnspotlightRecording' => 'unspotlight',
142
145
  'GetMyProfile' => 'my_profile',
143
146
  'GetTodolistOrGroup' => 'get',
144
147
  # The plain `update` name belongs to the merge-safe composite; the raw
@@ -175,6 +178,9 @@ class ServiceGenerator
175
178
  'Search' => 'search',
176
179
  'CreateProjectFromTemplate' => 'create_project',
177
180
  'GetProjectConstruction' => 'get_construction',
181
+ 'GetTemplateLibrary' => 'get_library',
182
+ 'CreateTemplateLibraryCopy' => 'create_library_copy',
183
+ 'GetTemplateLibraryCopy' => 'get_library_copy',
178
184
  'GetRecordingTimesheet' => 'for_recording',
179
185
  'GetProjectTimesheet' => 'for_project',
180
186
  'GetTimesheetReport' => 'report',
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: basecamp-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.0
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Basecamp
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-12 00:00:00.000000000 Z
11
+ date: 2026-09-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -184,6 +184,7 @@ files:
184
184
  - lib/basecamp/generated/services/base_service.rb
185
185
  - lib/basecamp/generated/services/bookmarks_service.rb
186
186
  - lib/basecamp/generated/services/boosts_service.rb
187
+ - lib/basecamp/generated/services/bubble_ups_service.rb
187
188
  - lib/basecamp/generated/services/calendars_service.rb
188
189
  - lib/basecamp/generated/services/campfires_service.rb
189
190
  - lib/basecamp/generated/services/card_columns_service.rb
@@ -262,6 +263,7 @@ files:
262
263
  - lib/basecamp/oauth_token_provider.rb
263
264
  - lib/basecamp/operation_info.rb
264
265
  - lib/basecamp/operation_result.rb
266
+ - lib/basecamp/people_confirmation_required_error.rb
265
267
  - lib/basecamp/rate_limit_error.rb
266
268
  - lib/basecamp/request_info.rb
267
269
  - lib/basecamp/request_result.rb