basecamp-sdk 0.15.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f6c1f02712a441b2b7772199b8029904b0c254be6c9b687a94a8d957f7d8a066
4
- data.tar.gz: f0750c8e75561aaa908630440f0b2c4276a54abb653418361cc4363883497d9a
3
+ metadata.gz: 67cf91504a5aadff973463d83b4a4de98aa4ad35e2dd7d0d4e5f4f26021e8423
4
+ data.tar.gz: 88eacbb6628831a01e6a9cb56503618377ffec4a85ad85d5a514b025ff53dd2a
5
5
  SHA512:
6
- metadata.gz: a7d6ecde9f18d17b9baadc7b827b6d396c0f6e63ab304d93800ee2e35f3803af9d668965b5d5885a408d926e780700f7fe0320d68375924a6647cce2462eee20
7
- data.tar.gz: a445a4123c2ebf596026035d347dbe99883b207a650003d2651f0dbc77eed9c0f4634703555e17af674aca87e015fdbe17f38a40e9370deff1b73a42cec6d042
6
+ metadata.gz: 8cede9f49e112c50b65d7c7c4901f442cba400211bfe4676a9af95a95ac4ca5da1a1477c529f72456745a0f8423b766162c151135b89a2c52ce1abbacb9eb17f
7
+ data.tar.gz: 695198c7697799719f0d66657463ed8ad76739bcd3bbe11877ad0a16d1fdd6b20602e2d6efa623c9d6e05a2b78ef5528e346ebec5975dd1697e5379e96b1e640
@@ -17,11 +17,12 @@ module Basecamp
17
17
  # Creates an ApiError from an HTTP status code.
18
18
  # @param status [Integer] HTTP status code
19
19
  # @param message [String, nil] optional error message
20
+ # @param hint [String, nil] optional hint (SPEC section 6 step 3)
20
21
  # @return [ApiError]
21
- def self.from_status(status, message = nil)
22
+ def self.from_status(status, message = nil, hint: nil)
22
23
  message ||= "Request failed (HTTP #{status})"
23
24
  retryable = status >= 500 && status < 600
24
- new(message, http_status: status, retryable: retryable)
25
+ new(message, http_status: status, hint: hint, retryable: retryable)
25
26
  end
26
27
  end
27
28
  end
@@ -507,6 +507,11 @@ module Basecamp
507
507
  service(:bookmarks) { Services::BookmarksService.new(self) }
508
508
  end
509
509
 
510
+ # @return [Services::BubbleUpsService]
511
+ def bubble_ups
512
+ service(:bubble_ups) { Services::BubbleUpsService.new(self) }
513
+ end
514
+
510
515
  # @return [Services::FoldersService]
511
516
  def folders
512
517
  service(:folders) { Services::FoldersService.new(self) }
@@ -635,7 +640,16 @@ module Basecamp
635
640
  nil
636
641
  end
637
642
  unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
638
- raise ApiError.new("redirect to undialable download URL: #{Security.truncate(url)}")
643
+ # SPEC §9: the signed URL is a credential — render its origin alone,
644
+ # projected from the parse, or the fixed token when no complete origin
645
+ # exists. Truncating the whole URL kept a short query intact.
646
+ origin = if uri&.scheme && uri&.host && !uri.host.empty?
647
+ port = uri.port && uri.port != uri.default_port ? ":#{uri.port}" : ""
648
+ "#{uri.scheme}://#{uri.host}#{port}"
649
+ else
650
+ "unparsable"
651
+ end
652
+ raise ApiError.new("redirect to undialable download URL: #{origin}")
639
653
  end
640
654
 
641
655
  http_client = Net::HTTP.new(uri.host, uri.port)
@@ -652,8 +666,12 @@ module Basecamp
652
666
  # Redirect Policy"). Stated here so a move to a following client
653
667
  # (Faraday, Net::HTTP.get_response's callers) has to argue with it.
654
668
  response = http_client.request(request)
655
- rescue StandardError => e
656
- raise NetworkError.new("Download failed: #{e.message}", cause: e)
669
+ rescue StandardError
670
+ # SPEC §9: the transport error renders the signed URL, so neither its
671
+ # message nor the exception itself survives. cause: nil at the raise
672
+ # site — MRI sets the built-in cause at raise time past the class's
673
+ # stored cause: keyword (same pattern as oauth/exchange.rb).
674
+ raise NetworkError.new("Download failed"), cause: nil
657
675
  end
658
676
 
659
677
  # The exact set hop 1 dispatches on, not Net::HTTPRedirection — that
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://basecamp.com/schemas/sdk-metadata.json",
3
3
  "version": "1.0.0",
4
- "generated": "2026-08-12T19:58:36Z",
4
+ "generated": "2026-09-03T02:27:35Z",
5
5
  "operations": {
6
6
  "GetAccount": {
7
7
  "retry": {
@@ -1576,6 +1576,17 @@
1576
1576
  "maxPageSize": 50
1577
1577
  }
1578
1578
  },
1579
+ "ListRecentProjects": {
1580
+ "retry": {
1581
+ "maxAttempts": 3,
1582
+ "baseDelayMs": 1000,
1583
+ "backoff": "exponential",
1584
+ "retryOn": [
1585
+ 429,
1586
+ 503
1587
+ ]
1588
+ }
1589
+ },
1579
1590
  "MarkAsRead": {
1580
1591
  "retry": {
1581
1592
  "maxAttempts": 2,
@@ -1806,6 +1817,20 @@
1806
1817
  "natural": true
1807
1818
  }
1808
1819
  },
1820
+ "RecordProjectVisit": {
1821
+ "retry": {
1822
+ "maxAttempts": 3,
1823
+ "baseDelayMs": 1000,
1824
+ "backoff": "exponential",
1825
+ "retryOn": [
1826
+ 429,
1827
+ 503
1828
+ ]
1829
+ },
1830
+ "idempotent": {
1831
+ "natural": true
1832
+ }
1833
+ },
1809
1834
  "UnarchiveProject": {
1810
1835
  "retry": {
1811
1836
  "maxAttempts": 3,
@@ -2144,6 +2169,34 @@
2144
2169
  ]
2145
2170
  }
2146
2171
  },
2172
+ "CreateBubbleUp": {
2173
+ "retry": {
2174
+ "maxAttempts": 3,
2175
+ "baseDelayMs": 1000,
2176
+ "backoff": "exponential",
2177
+ "retryOn": [
2178
+ 429,
2179
+ 503
2180
+ ]
2181
+ },
2182
+ "idempotent": {
2183
+ "natural": true
2184
+ }
2185
+ },
2186
+ "DeleteBubbleUp": {
2187
+ "retry": {
2188
+ "maxAttempts": 3,
2189
+ "baseDelayMs": 1000,
2190
+ "backoff": "exponential",
2191
+ "retryOn": [
2192
+ 429,
2193
+ 503
2194
+ ]
2195
+ },
2196
+ "idempotent": {
2197
+ "natural": true
2198
+ }
2199
+ },
2147
2200
  "SetClientVisibility": {
2148
2201
  "retry": {
2149
2202
  "maxAttempts": 3,
@@ -2228,6 +2281,34 @@
2228
2281
  ]
2229
2282
  }
2230
2283
  },
2284
+ "SpotlightRecording": {
2285
+ "retry": {
2286
+ "maxAttempts": 3,
2287
+ "baseDelayMs": 1000,
2288
+ "backoff": "exponential",
2289
+ "retryOn": [
2290
+ 429,
2291
+ 503
2292
+ ]
2293
+ },
2294
+ "idempotent": {
2295
+ "natural": true
2296
+ }
2297
+ },
2298
+ "UnspotlightRecording": {
2299
+ "retry": {
2300
+ "maxAttempts": 3,
2301
+ "baseDelayMs": 1000,
2302
+ "backoff": "exponential",
2303
+ "retryOn": [
2304
+ 429,
2305
+ 503
2306
+ ]
2307
+ },
2308
+ "idempotent": {
2309
+ "natural": true
2310
+ }
2311
+ },
2231
2312
  "UnarchiveRecording": {
2232
2313
  "retry": {
2233
2314
  "maxAttempts": 3,
@@ -2668,6 +2749,39 @@
2668
2749
  "natural": true
2669
2750
  }
2670
2751
  },
2752
+ "GetTemplateLibrary": {
2753
+ "retry": {
2754
+ "maxAttempts": 3,
2755
+ "baseDelayMs": 1000,
2756
+ "backoff": "exponential",
2757
+ "retryOn": [
2758
+ 429,
2759
+ 503
2760
+ ]
2761
+ }
2762
+ },
2763
+ "CreateTemplateLibraryCopy": {
2764
+ "retry": {
2765
+ "maxAttempts": 2,
2766
+ "baseDelayMs": 1000,
2767
+ "backoff": "exponential",
2768
+ "retryOn": [
2769
+ 429,
2770
+ 503
2771
+ ]
2772
+ }
2773
+ },
2774
+ "GetTemplateLibraryCopy": {
2775
+ "retry": {
2776
+ "maxAttempts": 3,
2777
+ "baseDelayMs": 1000,
2778
+ "backoff": "exponential",
2779
+ "retryOn": [
2780
+ 429,
2781
+ 503
2782
+ ]
2783
+ }
2784
+ },
2671
2785
  "ListTemplates": {
2672
2786
  "retry": {
2673
2787
  "maxAttempts": 3,
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Services
5
+ # Service for BubbleUps operations
6
+ #
7
+ # @generated from OpenAPI spec
8
+ class BubbleUpsService < BaseService
9
+
10
+ # Bubble up a recording for the current user, resurfacing it in the current
11
+ # @param recording_id [Integer] recording id ID
12
+ # @param at [String, nil] Timing for the bubble-up. `"now"` bubbles up immediately; a scheduling
13
+ # keyword (`"today"`, `"tomorrow"`, `"weekend"`, `"next_week"`) or an ISO8601
14
+ # date (e.g. `"2026-09-10"`) schedules it to resurface later. bc3 requires a
15
+ # value — omitting `at` errors server-side (`Date.iso8601(nil)`) — so send
16
+ # `"now"` for the immediate case.
17
+ # @return [void]
18
+ def create_bubble_up(recording_id:, at: nil)
19
+ with_operation(service: "bubbleups", operation: "create_bubble_up", is_mutation: true, resource_id: recording_id) do
20
+ http_post("/recordings/#{recording_id}/bubble_up.json", body: compact_params(at: at))
21
+ nil
22
+ end
23
+ end
24
+
25
+ # Remove the current user's bubble-up from a recording (returns 204 No Content).
26
+ # @param recording_id [Integer] recording id ID
27
+ # @return [void]
28
+ def delete_bubble_up(recording_id:)
29
+ with_operation(service: "bubbleups", operation: "delete_bubble_up", is_mutation: true, resource_id: recording_id) do
30
+ http_delete("/recordings/#{recording_id}/bubble_up.json")
31
+ nil
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -7,6 +7,14 @@ module Basecamp
7
7
  # @generated from OpenAPI spec
8
8
  class ProjectsService < BaseService
9
9
 
10
+ # List the projects the current user has most recently visited, most recent visit first.
11
+ # @return [Array<Hash>] response data
12
+ def list_recent_projects()
13
+ with_operation(service: "projects", operation: "list_recent_projects", is_mutation: false) do
14
+ http_get("/my/recent_projects.json", operation: "ListRecentProjects").json
15
+ end
16
+ end
17
+
10
18
  # List projects (active by default; optionally archived/trashed)
11
19
  # @param status [String, nil] active|archived|trashed
12
20
  # @param page [Integer, nil] Page number for paginating through results. Defaults to 1. A positive value selects exactly that page, not a starting offset; see SPEC section 8.
@@ -61,6 +69,16 @@ module Basecamp
61
69
  end
62
70
  end
63
71
 
72
+ # Record that the current user visited a project, moving it to the front of ListRecentProjects (returns 204 No Content).
73
+ # @param project_id [Integer] project id ID
74
+ # @return [void]
75
+ def record_project_visit(project_id:)
76
+ with_operation(service: "projects", operation: "record_project_visit", is_mutation: true, project_id: project_id) do
77
+ http_post("/projects/#{project_id}/recent_visit.json")
78
+ nil
79
+ end
80
+ end
81
+
64
82
  # Restore a project to active status from trash as well as from the archive (returns 204 No Content).
65
83
  # @param project_id [Integer] project id ID
66
84
  # @return [void]
@@ -23,6 +23,25 @@ module Basecamp
23
23
  end
24
24
  end
25
25
 
26
+ # Put a recording's card in the spotlight area on its project or template home page.
27
+ # @param recording_id [Integer] recording id ID
28
+ # @return [Hash] response data
29
+ def spotlight(recording_id:)
30
+ with_operation(service: "recordings", operation: "spotlight", is_mutation: true, resource_id: recording_id) do
31
+ http_post("/recordings/#{recording_id}/spotlight.json").json
32
+ end
33
+ end
34
+
35
+ # Remove a recording from the spotlight area.
36
+ # @param recording_id [Integer] recording id ID
37
+ # @return [void]
38
+ def unspotlight(recording_id:)
39
+ with_operation(service: "recordings", operation: "unspotlight", is_mutation: true, resource_id: recording_id) do
40
+ http_delete("/recordings/#{recording_id}/spotlight.json")
41
+ nil
42
+ end
43
+ end
44
+
26
45
  # Unarchive a recording (restore to active status)
27
46
  # @param recording_id [Integer] recording id ID
28
47
  # @return [void]
@@ -7,6 +7,34 @@ module Basecamp
7
7
  # @generated from OpenAPI spec
8
8
  class TemplatesService < BaseService
9
9
 
10
+ # Get the account's to-do list template library
11
+ # @return [Hash] response data
12
+ def get_library()
13
+ with_operation(service: "templates", operation: "get_library", is_mutation: false) do
14
+ http_get("/template_library.json", operation: "GetTemplateLibrary").json
15
+ end
16
+ end
17
+
18
+ # Start copying a to-do list template into a project
19
+ # @param template_recording_id [Integer] template recording id
20
+ # @param destination_parent_id [Integer] destination parent id
21
+ # @param adding_people_confirmed [Boolean, nil] Confirm granting destination-project access to people referenced by the template.
22
+ # @return [Hash] response data
23
+ def create_library_copy(template_recording_id:, destination_parent_id:, adding_people_confirmed: nil)
24
+ with_operation(service: "templates", operation: "create_library_copy", is_mutation: true) do
25
+ http_post("/template_library/copies.json", body: compact_params(template_recording_id: template_recording_id, destination_parent_id: destination_parent_id, adding_people_confirmed: adding_people_confirmed)).json
26
+ end
27
+ end
28
+
29
+ # Get the current status of a to-do list template copy
30
+ # @param copy_id [Integer] copy id ID
31
+ # @return [Hash] response data
32
+ def get_library_copy(copy_id:)
33
+ with_operation(service: "templates", operation: "get_library_copy", is_mutation: false, resource_id: copy_id) do
34
+ http_get("/template_library/copies/#{copy_id}", operation: "GetTemplateLibraryCopy").json
35
+ end
36
+ end
37
+
10
38
  # List all templates visible to the current user
11
39
  # @param status [String, nil] active|archived|trashed
12
40
  # @param page [Integer, nil] Page number for paginating through results. Defaults to 1. A positive value selects exactly that page, not a starting offset; see SPEC section 8.
@@ -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-03T02:27:36Z
5
5
 
6
6
  require "json"
7
7
  require "time"
@@ -3169,7 +3169,7 @@ module Basecamp
3169
3169
  include TypeHelpers
3170
3170
  # @!attribute [rw] clientside
3171
3171
  # @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
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, :star_url, :starred, :start_date
3173
3173
 
3174
3174
  # @return [Array<Symbol>]
3175
3175
  def self.required_fields
@@ -3193,6 +3193,8 @@ module Basecamp
3193
3193
  @dock = parse_array(data["dock"], "DockItem")
3194
3194
  @end_date = data["end_date"]
3195
3195
  @purpose = data["purpose"]
3196
+ @star_url = data["star_url"]
3197
+ @starred = parse_boolean(data["starred"])
3196
3198
  @start_date = data["start_date"]
3197
3199
  end
3198
3200
 
@@ -3214,6 +3216,8 @@ module Basecamp
3214
3216
  "dock" => @dock,
3215
3217
  "end_date" => @end_date,
3216
3218
  "purpose" => @purpose,
3219
+ "star_url" => @star_url,
3220
+ "starred" => @starred,
3217
3221
  "start_date" => @start_date,
3218
3222
  }.compact
3219
3223
  end
@@ -4289,6 +4293,99 @@ module Basecamp
4289
4293
  end
4290
4294
  end
4291
4295
 
4296
+ # TemplateLibrary
4297
+ class TemplateLibrary
4298
+ include TypeHelpers
4299
+ attr_accessor :bucket, :todolists, :todoset
4300
+
4301
+ # @return [Array<Symbol>]
4302
+ def self.required_fields
4303
+ %i[bucket todolists todoset].freeze
4304
+ end
4305
+
4306
+ def initialize(data = {})
4307
+ @bucket = parse_type(data["bucket"], "RecordingBucket")
4308
+ @todolists = parse_array(data["todolists"], "Todolist")
4309
+ @todoset = parse_type(data["todoset"], "RecordingParent")
4310
+ end
4311
+
4312
+ def to_h
4313
+ {
4314
+ "bucket" => @bucket,
4315
+ "todolists" => @todolists,
4316
+ "todoset" => @todoset,
4317
+ }.compact
4318
+ end
4319
+
4320
+ def to_json(*args)
4321
+ to_h.to_json(*args)
4322
+ end
4323
+ end
4324
+
4325
+ # TemplateLibraryConfirmationPerson
4326
+ class TemplateLibraryConfirmationPerson
4327
+ include TypeHelpers
4328
+ attr_accessor :avatar_url, :id, :name
4329
+
4330
+ # @return [Array<Symbol>]
4331
+ def self.required_fields
4332
+ %i[avatar_url id name].freeze
4333
+ end
4334
+
4335
+ def initialize(data = {})
4336
+ @avatar_url = data["avatar_url"]
4337
+ @id = parse_integer(data["id"])
4338
+ @name = data["name"]
4339
+ end
4340
+
4341
+ def to_h
4342
+ {
4343
+ "avatar_url" => @avatar_url,
4344
+ "id" => @id,
4345
+ "name" => @name,
4346
+ }.compact
4347
+ end
4348
+
4349
+ def to_json(*args)
4350
+ to_h.to_json(*args)
4351
+ end
4352
+ end
4353
+
4354
+ # TemplateLibraryCopy
4355
+ class TemplateLibraryCopy
4356
+ include TypeHelpers
4357
+ attr_accessor :destination_parent_id, :id, :source_recording_id, :status, :url, :destination_todolist
4358
+
4359
+ # @return [Array<Symbol>]
4360
+ def self.required_fields
4361
+ %i[destination_parent_id id source_recording_id status url].freeze
4362
+ end
4363
+
4364
+ def initialize(data = {})
4365
+ @destination_parent_id = parse_integer(data["destination_parent_id"])
4366
+ @id = parse_integer(data["id"])
4367
+ @source_recording_id = parse_integer(data["source_recording_id"])
4368
+ @status = data["status"]
4369
+ @url = data["url"]
4370
+ @destination_todolist = parse_type(data["destination_todolist"], "Todolist")
4371
+ end
4372
+
4373
+ def to_h
4374
+ {
4375
+ "destination_parent_id" => @destination_parent_id,
4376
+ "id" => @id,
4377
+ "source_recording_id" => @source_recording_id,
4378
+ "status" => @status,
4379
+ "url" => @url,
4380
+ "destination_todolist" => @destination_todolist,
4381
+ }.compact
4382
+ end
4383
+
4384
+ def to_json(*args)
4385
+ to_h.to_json(*args)
4386
+ end
4387
+ end
4388
+
4292
4389
  # TimelineAttachment
4293
4390
  class TimelineAttachment
4294
4391
  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
  )
@@ -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)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Basecamp
4
- VERSION = "0.15.0"
5
- API_VERSION = "2026-08-11"
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.15.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-22 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