basecamp-sdk 0.15.0 → 0.17.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.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Auto-generated from OpenAPI spec. Do not edit manually.
4
- # Generated: 2026-08-12T19:58:36Z
4
+ # Generated: 2026-09-08T23:57:39Z
5
5
 
6
6
  require "json"
7
7
  require "time"
@@ -1135,6 +1135,58 @@ module Basecamp
1135
1135
  end
1136
1136
  end
1137
1137
 
1138
+ # ClientInvitationError
1139
+ class ClientInvitationError
1140
+ include TypeHelpers
1141
+ attr_accessor :email_address, :messages
1142
+
1143
+ # @return [Array<Symbol>]
1144
+ def self.required_fields
1145
+ %i[email_address messages].freeze
1146
+ end
1147
+
1148
+ def initialize(data = {})
1149
+ @email_address = data["email_address"]
1150
+ @messages = data["messages"]
1151
+ end
1152
+
1153
+ def to_h
1154
+ {
1155
+ "email_address" => @email_address,
1156
+ "messages" => @messages,
1157
+ }.reject { |k, v| v.nil? && !["email_address"].include?(k) }
1158
+ end
1159
+
1160
+ def to_json(*args)
1161
+ to_h.to_json(*args)
1162
+ end
1163
+ end
1164
+
1165
+ # ClientInvitationErrors
1166
+ class ClientInvitationErrors
1167
+ include TypeHelpers
1168
+ attr_accessor :errors
1169
+
1170
+ # @return [Array<Symbol>]
1171
+ def self.required_fields
1172
+ %i[errors].freeze
1173
+ end
1174
+
1175
+ def initialize(data = {})
1176
+ @errors = parse_array(data["errors"], "ClientInvitationError")
1177
+ end
1178
+
1179
+ def to_h
1180
+ {
1181
+ "errors" => @errors,
1182
+ }.compact
1183
+ end
1184
+
1185
+ def to_json(*args)
1186
+ to_h.to_json(*args)
1187
+ end
1188
+ end
1189
+
1138
1190
  # ClientReply
1139
1191
  class ClientReply
1140
1192
  include TypeHelpers
@@ -1374,6 +1426,37 @@ module Basecamp
1374
1426
  end
1375
1427
  end
1376
1428
 
1429
+ # CreateClientRequest
1430
+ class CreateClientRequest
1431
+ include TypeHelpers
1432
+ attr_accessor :email_address, :company_name, :name, :title
1433
+
1434
+ # @return [Array<Symbol>]
1435
+ def self.required_fields
1436
+ %i[email_address].freeze
1437
+ end
1438
+
1439
+ def initialize(data = {})
1440
+ @email_address = data["email_address"]
1441
+ @company_name = data["company_name"]
1442
+ @name = data["name"]
1443
+ @title = data["title"]
1444
+ end
1445
+
1446
+ def to_h
1447
+ {
1448
+ "email_address" => @email_address,
1449
+ "company_name" => @company_name,
1450
+ "name" => @name,
1451
+ "title" => @title,
1452
+ }.compact
1453
+ end
1454
+
1455
+ def to_json(*args)
1456
+ to_h.to_json(*args)
1457
+ end
1458
+ end
1459
+
1377
1460
  # CreatePersonRequest
1378
1461
  class CreatePersonRequest
1379
1462
  include TypeHelpers
@@ -3169,7 +3252,7 @@ module Basecamp
3169
3252
  include TypeHelpers
3170
3253
  # @!attribute [rw] clientside
3171
3254
  # @deprecated This shape is deprecated since 2024-01: Use Client Visibility feature instead
3172
- attr_accessor :app_url, :created_at, :id, :name, :status, :updated_at, :url, :bookmark_url, :bookmarked, :client_company, :clients_enabled, :clientside, :description, :dock, :end_date, :purpose, :start_date
3255
+ attr_accessor :app_url, :created_at, :id, :name, :status, :updated_at, :url, :bookmark_url, :bookmarked, :client_company, :clients_enabled, :clientside, :description, :dock, :end_date, :purpose, :star_url, :starred, :start_date
3173
3256
 
3174
3257
  # @return [Array<Symbol>]
3175
3258
  def self.required_fields
@@ -3193,6 +3276,8 @@ module Basecamp
3193
3276
  @dock = parse_array(data["dock"], "DockItem")
3194
3277
  @end_date = data["end_date"]
3195
3278
  @purpose = data["purpose"]
3279
+ @star_url = data["star_url"]
3280
+ @starred = parse_boolean(data["starred"])
3196
3281
  @start_date = data["start_date"]
3197
3282
  end
3198
3283
 
@@ -3214,6 +3299,8 @@ module Basecamp
3214
3299
  "dock" => @dock,
3215
3300
  "end_date" => @end_date,
3216
3301
  "purpose" => @purpose,
3302
+ "star_url" => @star_url,
3303
+ "starred" => @starred,
3217
3304
  "start_date" => @start_date,
3218
3305
  }.compact
3219
3306
  end
@@ -3245,6 +3332,31 @@ module Basecamp
3245
3332
  end
3246
3333
  end
3247
3334
 
3335
+ # ProjectClientEnablement
3336
+ class ProjectClientEnablement
3337
+ include TypeHelpers
3338
+ attr_accessor :clients_enabled
3339
+
3340
+ # @return [Array<Symbol>]
3341
+ def self.required_fields
3342
+ %i[clients_enabled].freeze
3343
+ end
3344
+
3345
+ def initialize(data = {})
3346
+ @clients_enabled = parse_boolean(data["clients_enabled"])
3347
+ end
3348
+
3349
+ def to_h
3350
+ {
3351
+ "clients_enabled" => @clients_enabled,
3352
+ }.compact
3353
+ end
3354
+
3355
+ def to_json(*args)
3356
+ to_h.to_json(*args)
3357
+ end
3358
+ end
3359
+
3248
3360
  # ProjectConstruction
3249
3361
  class ProjectConstruction
3250
3362
  include TypeHelpers
@@ -4289,6 +4401,99 @@ module Basecamp
4289
4401
  end
4290
4402
  end
4291
4403
 
4404
+ # TemplateLibrary
4405
+ class TemplateLibrary
4406
+ include TypeHelpers
4407
+ attr_accessor :bucket, :todolists, :todoset
4408
+
4409
+ # @return [Array<Symbol>]
4410
+ def self.required_fields
4411
+ %i[bucket todolists todoset].freeze
4412
+ end
4413
+
4414
+ def initialize(data = {})
4415
+ @bucket = parse_type(data["bucket"], "RecordingBucket")
4416
+ @todolists = parse_array(data["todolists"], "Todolist")
4417
+ @todoset = parse_type(data["todoset"], "RecordingParent")
4418
+ end
4419
+
4420
+ def to_h
4421
+ {
4422
+ "bucket" => @bucket,
4423
+ "todolists" => @todolists,
4424
+ "todoset" => @todoset,
4425
+ }.compact
4426
+ end
4427
+
4428
+ def to_json(*args)
4429
+ to_h.to_json(*args)
4430
+ end
4431
+ end
4432
+
4433
+ # TemplateLibraryConfirmationPerson
4434
+ class TemplateLibraryConfirmationPerson
4435
+ include TypeHelpers
4436
+ attr_accessor :avatar_url, :id, :name
4437
+
4438
+ # @return [Array<Symbol>]
4439
+ def self.required_fields
4440
+ %i[avatar_url id name].freeze
4441
+ end
4442
+
4443
+ def initialize(data = {})
4444
+ @avatar_url = data["avatar_url"]
4445
+ @id = parse_integer(data["id"])
4446
+ @name = data["name"]
4447
+ end
4448
+
4449
+ def to_h
4450
+ {
4451
+ "avatar_url" => @avatar_url,
4452
+ "id" => @id,
4453
+ "name" => @name,
4454
+ }.compact
4455
+ end
4456
+
4457
+ def to_json(*args)
4458
+ to_h.to_json(*args)
4459
+ end
4460
+ end
4461
+
4462
+ # TemplateLibraryCopy
4463
+ class TemplateLibraryCopy
4464
+ include TypeHelpers
4465
+ attr_accessor :destination_parent_id, :id, :source_recording_id, :status, :url, :destination_todolist
4466
+
4467
+ # @return [Array<Symbol>]
4468
+ def self.required_fields
4469
+ %i[destination_parent_id id source_recording_id status url].freeze
4470
+ end
4471
+
4472
+ def initialize(data = {})
4473
+ @destination_parent_id = parse_integer(data["destination_parent_id"])
4474
+ @id = parse_integer(data["id"])
4475
+ @source_recording_id = parse_integer(data["source_recording_id"])
4476
+ @status = data["status"]
4477
+ @url = data["url"]
4478
+ @destination_todolist = parse_type(data["destination_todolist"], "Todolist")
4479
+ end
4480
+
4481
+ def to_h
4482
+ {
4483
+ "destination_parent_id" => @destination_parent_id,
4484
+ "id" => @id,
4485
+ "source_recording_id" => @source_recording_id,
4486
+ "status" => @status,
4487
+ "url" => @url,
4488
+ "destination_todolist" => @destination_todolist,
4489
+ }.compact
4490
+ end
4491
+
4492
+ def to_json(*args)
4493
+ to_h.to_json(*args)
4494
+ end
4495
+ end
4496
+
4292
4497
  # TimelineAttachment
4293
4498
  class TimelineAttachment
4294
4499
  include TypeHelpers
data/lib/basecamp/http.rb CHANGED
@@ -176,7 +176,11 @@ module Basecamp
176
176
  # @param url [String] absolute URL
177
177
  # @return [Response]
178
178
  def get_download(url)
179
- request_with_retry(:get, url, retry_on: DOWNLOAD_RETRY_ON, accept: nil)
179
+ # download: true projects this flow's hooks and transport errors (SPEC
180
+ # §9): the caller's URL can smuggle a signed query through the rewrite
181
+ # into hop 1, and a transport error renders the URL it failed on. The
182
+ # wire request keeps the query; only the renderings are projected.
183
+ request_with_retry(:get, url, retry_on: DOWNLOAD_RETRY_ON, accept: nil, download: true)
180
184
  end
181
185
 
182
186
  # Fetches all pages of a paginated resource.
@@ -396,7 +400,7 @@ module Basecamp
396
400
  end
397
401
 
398
402
  def request_with_retry(method, url, params: {}, allow_cross_origin: false, operation: nil, retry_on: nil,
399
- accept: "application/json")
403
+ accept: "application/json", download: false)
400
404
  op_retry = operation && Http.operation_retry(operation)
401
405
  # The cap is floored at one attempt on every path: whether a request
402
406
  # reaches the wire at all must not depend on whether the operation
@@ -422,7 +426,7 @@ module Basecamp
422
426
 
423
427
  begin
424
428
  return single_request(method, url, params: params, body: nil, attempt: attempt,
425
- allow_cross_origin: allow_cross_origin, accept: accept, refresh_replay: false)
429
+ allow_cross_origin: allow_cross_origin, accept: accept, refresh_replay: false, download: download)
426
430
  rescue Basecamp::AuthError => e
427
431
  # SPEC §4: the refresh replay is a request on the wire, so it spends
428
432
  # an attempt from THIS budget rather than an uncounted one inside
@@ -474,8 +478,9 @@ module Basecamp
474
478
 
475
479
  delay = calculate_delay(attempt, error.retry_after)
476
480
 
477
- @hooks.on_retry(RequestInfo.new(method: method.to_s.upcase, url: url, attempt: attempt), attempt + 1, error,
478
- delay)
481
+ hook_url = download ? Security.display_url(url) : url
482
+ @hooks.on_retry(RequestInfo.new(method: method.to_s.upcase, url: hook_url, attempt: attempt),
483
+ attempt + 1, error, delay)
479
484
  sleep(delay)
480
485
  end
481
486
  end
@@ -511,14 +516,19 @@ module Basecamp
511
516
  end
512
517
 
513
518
  def single_request(method, url, params:, body:, attempt:, retry_count: 0, allow_cross_origin: false,
514
- accept: "application/json", refresh_replay: true)
519
+ accept: "application/json", refresh_replay: true, download: false)
515
520
  assert_credential_origin!(url, allow_cross_origin)
516
- info = RequestInfo.new(method: method.to_s.upcase, url: url, attempt: attempt)
521
+ # download: the SPEC §9 projection for a URL whose query can carry a
522
+ # credential (download hop 1) — hooks see origin+path, and a transport
523
+ # error is severed below; the wire request keeps url.
524
+ info = RequestInfo.new(method: method.to_s.upcase, url: download ? Security.display_url(url) : url,
525
+ attempt: attempt)
517
526
  @hooks.on_request_start(info)
518
527
 
519
528
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
520
529
 
521
- begin
530
+ severed = nil
531
+ result = begin
522
532
  response = @faraday.run_request(method, url, body, request_headers(accept: accept)) do |req|
523
533
  req.params.merge!(params) if params.any?
524
534
  end
@@ -551,17 +561,29 @@ module Basecamp
551
561
  && @token_refreshed
552
562
  @token_refreshed = false
553
563
  return single_request(method, url, params: params, body: body, attempt: attempt, retry_count: retry_count + 1,
554
- allow_cross_origin: allow_cross_origin, accept: accept, refresh_replay: refresh_replay)
564
+ allow_cross_origin: allow_cross_origin, accept: accept, refresh_replay: refresh_replay, download: download)
555
565
  end
556
566
 
557
- raise error
567
+ # On a download, a status error is raised below the rescue too: the
568
+ # Faraday exception retains the request (SPEC §9), and raising here
569
+ # would make it MRI's implicit cause.
570
+ raise error unless download
571
+
572
+ severed = error
558
573
  rescue Faraday::Error => e
559
574
  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
560
- error = Basecamp::NetworkError.new("Connection failed", cause: e)
561
- result = RequestResult.new(duration: duration, error: error)
562
- @hooks.on_request_end(info, result)
563
- raise error
575
+ # SPEC §9: on a download hop 1 the Faraday error can render the URL it
576
+ # failed on, so it is neither the cause nor the hint, and it is raised
577
+ # below — outside this rescue, with cause: nil — so MRI's implicit
578
+ # cause is severed too. Every other request keeps its diagnostic.
579
+ error = download ? Basecamp::NetworkError.new : Basecamp::NetworkError.new("Connection failed", cause: e)
580
+ @hooks.on_request_end(info, RequestResult.new(duration: duration, error: error))
581
+ raise error unless download
582
+
583
+ severed = error
564
584
  end
585
+
586
+ severed ? raise(severed, cause: nil) : result
565
587
  end
566
588
 
567
589
  # accept: nil is the binary-download carve-out (SPEC §14): hop 1 sends
@@ -624,38 +646,48 @@ module Basecamp
624
646
 
625
647
  retry_after = parse_retry_after(headers["Retry-After"] || headers["retry-after"])
626
648
  request_id = headers["X-Request-Id"] || headers["x-request-id"]
649
+ # SPEC §6 step 3: a body's error_description becomes the hint. Class
650
+ # defaults (AuthError, ForbiddenError) still apply when the body has none.
651
+ hint = Basecamp.parse_error_hint(body)
627
652
 
628
653
  err = case status
629
654
  when 401
630
655
  # Try token refresh; flag for caller to retry
631
656
  @token_refreshed = refresh_on_401 && @token_provider&.refreshable? && @token_provider.refresh
632
- Basecamp::AuthError.new("Authentication failed")
657
+ Basecamp::AuthError.new("Authentication failed", hint: hint)
633
658
  when 403
634
- Basecamp::ForbiddenError.new("Access denied")
659
+ Basecamp::ForbiddenError.new("Access denied", hint: hint)
635
660
  when 404
636
661
  message = Security.truncate(Basecamp.parse_error_message(body) || "Not found")
637
- Basecamp::NotFoundError.new(message: message)
662
+ Basecamp::NotFoundError.new(message: message, hint: hint)
638
663
  when 429
639
- Basecamp::RateLimitError.new(retry_after: retry_after)
664
+ Basecamp::RateLimitError.new(retry_after: retry_after, hint: hint)
640
665
  when 400, 422
641
666
  field_errors = Basecamp.parse_field_errors(body)
642
667
  message = Security.truncate(
643
668
  Basecamp.compose_validation_message(Basecamp.parse_error_message(body), field_errors) || "Validation failed"
644
669
  )
645
- Basecamp::ValidationError.new(message, http_status: status, field_errors: field_errors)
670
+ people = status == 422 ? Basecamp.parse_template_library_confirmation_people(body) : nil
671
+ if people
672
+ Basecamp::PeopleConfirmationRequiredError.new(
673
+ message, people: people, hint: hint, http_status: status, field_errors: field_errors
674
+ )
675
+ else
676
+ Basecamp::ValidationError.new(message, hint: hint, http_status: status, field_errors: field_errors)
677
+ end
646
678
  when 507
647
679
  # A 5xx status carrying a client fact: the account is out of storage, or
648
680
  # at its webhook ceiling. Retrying cannot satisfy it, so this is decided
649
681
  # before the 5xx arms below.
650
682
  message = Security.truncate(Basecamp.parse_error_message(body) || "Account limit reached")
651
- Basecamp::LimitExceededError.new(message)
683
+ Basecamp::LimitExceededError.new(message, hint: hint)
652
684
  when 500
653
- Basecamp::ApiError.new("Server error (500)", http_status: 500, retryable: true)
685
+ Basecamp::ApiError.new("Server error (500)", http_status: 500, retryable: true, hint: hint)
654
686
  when 502, 503, 504
655
- Basecamp::ApiError.new("Gateway error (#{status})", http_status: status, retryable: true)
687
+ Basecamp::ApiError.new("Gateway error (#{status})", http_status: status, retryable: true, hint: hint)
656
688
  else
657
689
  message = Security.truncate(Basecamp.parse_error_message(body) || "Request failed (HTTP #{status})")
658
- Basecamp::ApiError.from_status(status || 0, message)
690
+ Basecamp::ApiError.from_status(status || 0, message, hint: hint)
659
691
  end
660
692
 
661
693
  err.instance_variable_set(:@request_id, request_id) if request_id
@@ -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
  )