basecamp-sdk 0.14.0 → 0.15.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: 9b770f1606323f83fb028ebb44d51e0d2500664a6d88ec9209dc227807ace197
4
- data.tar.gz: 418cf25fbdc65f25db5e9d816565cd20a7f2e3d0c06c5a147dcf896c38d2ec9e
3
+ metadata.gz: f6c1f02712a441b2b7772199b8029904b0c254be6c9b687a94a8d957f7d8a066
4
+ data.tar.gz: f0750c8e75561aaa908630440f0b2c4276a54abb653418361cc4363883497d9a
5
5
  SHA512:
6
- metadata.gz: 82caf3dad37f93e3b9ce65ad2da90b798efa466d0a2fc8e25d63ea84778357a436fe0da8615bbde3f03c3836eb9077dee20b35775ab283900437e575a64aa99e
7
- data.tar.gz: c17f87a5bac84d66e89edf3725fb3321fa768b31d868d8214f1dbf8326a6361f73dd5acf355c4a22712a9434fe5514d0b750edd2eb8d6ce15468a0867f44419d
6
+ metadata.gz: a7d6ecde9f18d17b9baadc7b827b6d396c0f6e63ab304d93800ee2e35f3803af9d668965b5d5885a408d926e780700f7fe0320d68375924a6647cce2462eee20
7
+ data.tar.gz: a445a4123c2ebf596026035d347dbe99883b207a650003d2651f0dbc77eed9c0f4634703555e17af674aca87e015fdbe17f38a40e9370deff1b73a42cec6d042
data/README.md CHANGED
@@ -316,7 +316,7 @@ aborts before an oversized body is buffered.
316
316
 
317
317
  ## Services
318
318
 
319
- The SDK provides 46 account-scoped services. The table below covers the common ones; see `lib/basecamp/generated/services/` for the authoritative, complete set:
319
+ The SDK provides the full account-scoped service set documented in SPEC §5. The table below covers the common ones; see `lib/basecamp/generated/services/` for the authoritative, complete set:
320
320
 
321
321
  | Service | Description |
322
322
  |---------|-------------|
@@ -240,7 +240,9 @@ module Basecamp
240
240
  #
241
241
  # Handles the full download flow: URL rewriting to the configured API host,
242
242
  # authenticated first hop (which typically 302s to a signed download URL),
243
- # and unauthenticated second hop to fetch the actual file content.
243
+ # and unauthenticated second hop to fetch the actual file content. Neither
244
+ # hop follows a redirect on its own: hop 1's is the dispatch to hop 2, and
245
+ # a redirect on hop 2 is an error (SPEC §14 "Hop-2 Redirect Policy").
244
246
  #
245
247
  # @param raw_url [String] absolute download URL (e.g., from bc-attachment elements)
246
248
  # @return [DownloadResult] the download result with body, content_type, content_length, filename
@@ -616,7 +618,26 @@ module Basecamp
616
618
  end
617
619
 
618
620
  def fetch_signed_download(url)
619
- uri = URI.parse(url)
621
+ # The Location target is server-supplied, and this unauthenticated hop
622
+ # has no same_origin? gate to refuse it (a signed URL is legitimately
623
+ # cross-origin), so illegible targets must be refused here — as
624
+ # ApiError, matching the legible refusals Go and TypeScript surface
625
+ # when their dial rejects the scheme. One predicate states the whole
626
+ # invariant — a dialable absolute HTTP(S) URL — because piecemeal
627
+ # guards kept leaking: "it parses" admitted "ftp://", and "scheme is
628
+ # http(s)" admitted hostless "http:foo", which Net::HTTP::Get rejects
629
+ # with a raw ArgumentError before the dial (nothing is ever sent).
630
+ # URI.parse types the scheme (URI::HTTPS < URI::HTTP), and a nil or
631
+ # empty host ("http:foo", "http:///x") is undialable.
632
+ uri = begin
633
+ URI.parse(url)
634
+ rescue URI::Error
635
+ nil
636
+ end
637
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
638
+ raise ApiError.new("redirect to undialable download URL: #{Security.truncate(url)}")
639
+ end
640
+
620
641
  http_client = Net::HTTP.new(uri.host, uri.port)
621
642
  http_client.use_ssl = (uri.scheme == "https")
622
643
  http_client.open_timeout = config.timeout
@@ -625,11 +646,22 @@ module Basecamp
625
646
  request = Net::HTTP::Get.new(uri)
626
647
 
627
648
  begin
649
+ # Net::HTTP#request never follows a redirect, which is the policy:
650
+ # the signed URL is the one destination the API host named, and a
651
+ # redirect from it is refused below, not dialled (SPEC §14 "Hop-2
652
+ # Redirect Policy"). Stated here so a move to a following client
653
+ # (Faraday, Net::HTTP.get_response's callers) has to argue with it.
628
654
  response = http_client.request(request)
629
655
  rescue StandardError => e
630
656
  raise NetworkError.new("Download failed: #{e.message}", cause: e)
631
657
  end
632
658
 
659
+ # The exact set hop 1 dispatches on, not Net::HTTPRedirection — that
660
+ # class also covers 304, which is a cache answer, not a redirect.
661
+ if [ 301, 302, 303, 307, 308 ].include?(response.code.to_i)
662
+ raise ApiError.new("redirect #{response.code} on the signed download hop is not followed", http_status: response.code.to_i)
663
+ end
664
+
633
665
  unless response.is_a?(Net::HTTPSuccess)
634
666
  raise ApiError.new("download failed with status #{response.code}", http_status: response.code.to_i)
635
667
  end
@@ -35,7 +35,21 @@ module Basecamp
35
35
  attr_accessor :max_jitter
36
36
 
37
37
  # @return [Integer] maximum pages to fetch in paginated requests
38
- attr_accessor :max_pages
38
+ attr_reader :max_pages
39
+
40
+ # A validating writer, not attr_accessor: Http reads the config live at
41
+ # every page boundary, so `config.max_pages = -1` after construction
42
+ # replaced the validated cap with a value validate! would have refused —
43
+ # the same second door TypeScript's compile-time-only `readonly` left
44
+ # open. The config deliberately stays mutable (from_file → load_from_env
45
+ # re-validation is builder-style by design); only the cap's invariant
46
+ # must hold at assignment.
47
+ #
48
+ # @param value [Integer] maximum pages to fetch in paginated requests
49
+ def max_pages=(value)
50
+ validate_max_pages!(value)
51
+ @max_pages = value
52
+ end
39
53
 
40
54
  # Default values
41
55
  DEFAULT_BASE_URL = "https://3.basecampapi.com"
@@ -164,7 +178,9 @@ module Basecamp
164
178
  # @param path [String] path to JSON config file
165
179
  # @return [Config]
166
180
  def self.from_file(path)
167
- data = JSON.parse(File.read(path))
181
+ # UTF-8 regardless of process locale — JSON is UTF-8 (RFC 8259), and
182
+ # LC_ALL=C would otherwise read as US-ASCII.
183
+ data = JSON.parse(File.read(path, encoding: "UTF-8"))
168
184
  config = new(
169
185
  base_url: data["base_url"] || DEFAULT_BASE_URL,
170
186
  timeout: data["timeout"] || DEFAULT_TIMEOUT,
@@ -199,7 +215,13 @@ module Basecamp
199
215
  def validate!
200
216
  raise ArgumentError, "timeout must be positive" unless @timeout.is_a?(Numeric) && @timeout > 0
201
217
  raise ArgumentError, "max_retries must be non-negative" unless @max_retries.is_a?(Integer) && @max_retries >= 0
202
- raise ArgumentError, "max_pages must be positive" unless @max_pages.is_a?(Integer) && @max_pages > 0
218
+ validate_max_pages!(@max_pages)
219
+ end
220
+
221
+ # Shared by validate! and the max_pages= writer. Integer-only, so a
222
+ # boolean or Float::INFINITY is refused along with zero and negatives.
223
+ def validate_max_pages!(value)
224
+ raise ArgumentError, "max_pages must be positive" unless value.is_a?(Integer) && value > 0
203
225
  end
204
226
 
205
227
  def normalize_url(url)
@@ -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-07T11:12:00Z",
4
+ "generated": "2026-08-12T19:58:36Z",
5
5
  "operations": {
6
6
  "GetAccount": {
7
7
  "retry": {
@@ -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-07T11:12:00Z
4
+ # Generated: 2026-08-12T19:58:36Z
5
5
 
6
6
  require "json"
7
7
  require "time"
@@ -2672,23 +2672,23 @@ module Basecamp
2672
2672
  # MyAssignmentAssignee
2673
2673
  class MyAssignmentAssignee
2674
2674
  include TypeHelpers
2675
- attr_accessor :id, :avatar_url, :name
2675
+ attr_accessor :avatar_url, :id, :name
2676
2676
 
2677
2677
  # @return [Array<Symbol>]
2678
2678
  def self.required_fields
2679
- %i[id].freeze
2679
+ %i[avatar_url id name].freeze
2680
2680
  end
2681
2681
 
2682
2682
  def initialize(data = {})
2683
- @id = parse_integer(data["id"])
2684
2683
  @avatar_url = data["avatar_url"]
2684
+ @id = parse_integer(data["id"])
2685
2685
  @name = data["name"]
2686
2686
  end
2687
2687
 
2688
2688
  def to_h
2689
2689
  {
2690
- "id" => @id,
2691
2690
  "avatar_url" => @avatar_url,
2691
+ "id" => @id,
2692
2692
  "name" => @name,
2693
2693
  }.compact
2694
2694
  end
@@ -2955,23 +2955,23 @@ module Basecamp
2955
2955
  # OutOfOfficePerson
2956
2956
  class OutOfOfficePerson
2957
2957
  include TypeHelpers
2958
- attr_accessor :id, :avatar_url, :name
2958
+ attr_accessor :avatar_url, :id, :name
2959
2959
 
2960
2960
  # @return [Array<Symbol>]
2961
2961
  def self.required_fields
2962
- %i[id].freeze
2962
+ %i[avatar_url id name].freeze
2963
2963
  end
2964
2964
 
2965
2965
  def initialize(data = {})
2966
- @id = parse_integer(data["id"])
2967
2966
  @avatar_url = data["avatar_url"]
2967
+ @id = parse_integer(data["id"])
2968
2968
  @name = data["name"]
2969
2969
  end
2970
2970
 
2971
2971
  def to_h
2972
2972
  {
2973
- "id" => @id,
2974
2973
  "avatar_url" => @avatar_url,
2974
+ "id" => @id,
2975
2975
  "name" => @name,
2976
2976
  }.compact
2977
2977
  end
@@ -4025,62 +4025,114 @@ module Basecamp
4025
4025
  # SearchResult
4026
4026
  class SearchResult
4027
4027
  include TypeHelpers
4028
- attr_accessor :app_url, :content, :description, :id, :title, :type, :url, :bookmark_url, :bubble_up_url, :bucket, :content_attachments, :created_at, :creator, :description_attachments, :inherits_status, :parent, :plain_text_content, :plain_text_description, :status, :subject, :updated_at, :visible_to_clients
4028
+ attr_accessor :content, :description, :app_download_url, :app_url, :attachments, :bookmark_url, :boosts_count, :boosts_url, :bubble_up_url, :bucket, :byte_size, :cards_count, :cards_url, :color, :comment_count, :comments_count, :comments_url, :content_attachments, :content_type, :created_at, :creator, :description_attachments, :download_url, :filename, :height, :id, :image_url, :inherits_status, :language, :on_hold, :parent, :plain_text_content, :plain_text_description, :position, :preview_url, :previewable, :sound_url, :status, :subject, :subscribers, :subscription_url, :thumbnail_url, :title, :type, :updated_at, :url, :visible_to_clients, :width
4029
4029
 
4030
4030
  # @return [Array<Symbol>]
4031
4031
  def self.required_fields
4032
- %i[app_url content description id title type url].freeze
4032
+ %i[content description].freeze
4033
4033
  end
4034
4034
 
4035
4035
  def initialize(data = {})
4036
- @app_url = data["app_url"]
4037
4036
  @content = data["content"]
4038
4037
  @description = data["description"]
4039
- @id = parse_integer(data["id"])
4040
- @title = data["title"]
4041
- @type = data["type"]
4042
- @url = data["url"]
4038
+ @app_download_url = data["app_download_url"]
4039
+ @app_url = data["app_url"]
4040
+ @attachments = parse_array(data["attachments"], "SearchResultAttachment")
4043
4041
  @bookmark_url = data["bookmark_url"]
4042
+ @boosts_count = parse_integer(data["boosts_count"])
4043
+ @boosts_url = data["boosts_url"]
4044
4044
  @bubble_up_url = data["bubble_up_url"]
4045
4045
  @bucket = parse_type(data["bucket"], "RecordingBucket")
4046
+ @byte_size = parse_integer(data["byte_size"])
4047
+ @cards_count = parse_integer(data["cards_count"])
4048
+ @cards_url = data["cards_url"]
4049
+ @color = data["color"]
4050
+ @comment_count = parse_integer(data["comment_count"])
4051
+ @comments_count = parse_integer(data["comments_count"])
4052
+ @comments_url = data["comments_url"]
4046
4053
  @content_attachments = parse_array(data["content_attachments"], "RichTextAttachment")
4054
+ @content_type = data["content_type"]
4047
4055
  @created_at = parse_datetime(data["created_at"])
4048
4056
  @creator = parse_type(data["creator"], "Person")
4049
4057
  @description_attachments = parse_array(data["description_attachments"], "RichTextAttachment")
4058
+ @download_url = data["download_url"]
4059
+ @filename = data["filename"]
4060
+ @height = parse_integer(data["height"])
4061
+ @id = parse_integer(data["id"])
4062
+ @image_url = data["image_url"]
4050
4063
  @inherits_status = parse_boolean(data["inherits_status"])
4064
+ @language = data["language"]
4065
+ @on_hold = parse_type(data["on_hold"], "CardColumnOnHold")
4051
4066
  @parent = parse_type(data["parent"], "RecordingParent")
4052
4067
  @plain_text_content = data["plain_text_content"]
4053
4068
  @plain_text_description = data["plain_text_description"]
4069
+ @position = parse_integer(data["position"])
4070
+ @preview_url = data["preview_url"]
4071
+ @previewable = parse_boolean(data["previewable"])
4072
+ @sound_url = data["sound_url"]
4054
4073
  @status = data["status"]
4055
4074
  @subject = data["subject"]
4075
+ @subscribers = parse_array(data["subscribers"], "Person")
4076
+ @subscription_url = data["subscription_url"]
4077
+ @thumbnail_url = data["thumbnail_url"]
4078
+ @title = data["title"]
4079
+ @type = data["type"]
4056
4080
  @updated_at = parse_datetime(data["updated_at"])
4081
+ @url = data["url"]
4057
4082
  @visible_to_clients = parse_boolean(data["visible_to_clients"])
4083
+ @width = parse_integer(data["width"])
4058
4084
  end
4059
4085
 
4060
4086
  def to_h
4061
4087
  {
4062
- "app_url" => @app_url,
4063
4088
  "content" => @content,
4064
4089
  "description" => @description,
4065
- "id" => @id,
4066
- "title" => @title,
4067
- "type" => @type,
4068
- "url" => @url,
4090
+ "app_download_url" => @app_download_url,
4091
+ "app_url" => @app_url,
4092
+ "attachments" => @attachments,
4069
4093
  "bookmark_url" => @bookmark_url,
4094
+ "boosts_count" => @boosts_count,
4095
+ "boosts_url" => @boosts_url,
4070
4096
  "bubble_up_url" => @bubble_up_url,
4071
4097
  "bucket" => @bucket,
4098
+ "byte_size" => @byte_size,
4099
+ "cards_count" => @cards_count,
4100
+ "cards_url" => @cards_url,
4101
+ "color" => @color,
4102
+ "comment_count" => @comment_count,
4103
+ "comments_count" => @comments_count,
4104
+ "comments_url" => @comments_url,
4072
4105
  "content_attachments" => @content_attachments,
4106
+ "content_type" => @content_type,
4073
4107
  "created_at" => @created_at,
4074
4108
  "creator" => @creator,
4075
4109
  "description_attachments" => @description_attachments,
4110
+ "download_url" => @download_url,
4111
+ "filename" => @filename,
4112
+ "height" => @height,
4113
+ "id" => @id,
4114
+ "image_url" => @image_url,
4076
4115
  "inherits_status" => @inherits_status,
4116
+ "language" => @language,
4117
+ "on_hold" => @on_hold,
4077
4118
  "parent" => @parent,
4078
4119
  "plain_text_content" => @plain_text_content,
4079
4120
  "plain_text_description" => @plain_text_description,
4121
+ "position" => @position,
4122
+ "preview_url" => @preview_url,
4123
+ "previewable" => @previewable,
4124
+ "sound_url" => @sound_url,
4080
4125
  "status" => @status,
4081
4126
  "subject" => @subject,
4127
+ "subscribers" => @subscribers,
4128
+ "subscription_url" => @subscription_url,
4129
+ "thumbnail_url" => @thumbnail_url,
4130
+ "title" => @title,
4131
+ "type" => @type,
4082
4132
  "updated_at" => @updated_at,
4133
+ "url" => @url,
4083
4134
  "visible_to_clients" => @visible_to_clients,
4135
+ "width" => @width,
4084
4136
  }.reject { |k, v| v.nil? && !["content", "description"].include?(k) }
4085
4137
  end
4086
4138
 
@@ -4089,6 +4141,55 @@ module Basecamp
4089
4141
  end
4090
4142
  end
4091
4143
 
4144
+ # SearchResultAttachment
4145
+ class SearchResultAttachment
4146
+ include TypeHelpers
4147
+ attr_accessor :byte_size, :content_type, :download_url, :filename, :height, :id, :preview_url, :previewable, :sgid, :thumbnail_url, :title, :url, :width
4148
+
4149
+ # @return [Array<Symbol>]
4150
+ def self.required_fields
4151
+ %i[byte_size content_type download_url filename].freeze
4152
+ end
4153
+
4154
+ def initialize(data = {})
4155
+ @byte_size = parse_integer(data["byte_size"])
4156
+ @content_type = data["content_type"]
4157
+ @download_url = data["download_url"]
4158
+ @filename = data["filename"]
4159
+ @height = parse_integer(data["height"])
4160
+ @id = parse_integer(data["id"])
4161
+ @preview_url = data["preview_url"]
4162
+ @previewable = parse_boolean(data["previewable"])
4163
+ @sgid = data["sgid"]
4164
+ @thumbnail_url = data["thumbnail_url"]
4165
+ @title = data["title"]
4166
+ @url = data["url"]
4167
+ @width = parse_integer(data["width"])
4168
+ end
4169
+
4170
+ def to_h
4171
+ {
4172
+ "byte_size" => @byte_size,
4173
+ "content_type" => @content_type,
4174
+ "download_url" => @download_url,
4175
+ "filename" => @filename,
4176
+ "height" => @height,
4177
+ "id" => @id,
4178
+ "preview_url" => @preview_url,
4179
+ "previewable" => @previewable,
4180
+ "sgid" => @sgid,
4181
+ "thumbnail_url" => @thumbnail_url,
4182
+ "title" => @title,
4183
+ "url" => @url,
4184
+ "width" => @width,
4185
+ }.compact
4186
+ end
4187
+
4188
+ def to_json(*args)
4189
+ to_h.to_json(*args)
4190
+ end
4191
+ end
4192
+
4092
4193
  # SearchType
4093
4194
  class SearchType
4094
4195
  include TypeHelpers
@@ -4722,39 +4823,53 @@ module Basecamp
4722
4823
  # Tool
4723
4824
  class Tool
4724
4825
  include TypeHelpers
4725
- attr_accessor :created_at, :enabled, :id, :name, :title, :updated_at, :app_url, :bucket, :position, :status, :url
4826
+ attr_accessor :created_at, :creator, :id, :inherits_status, :title, :type, :updated_at, :visible_to_clients, :app_url, :bookmark_url, :bucket, :enabled, :name, :parent, :position, :status, :subscription_url, :url
4726
4827
 
4727
4828
  # @return [Array<Symbol>]
4728
4829
  def self.required_fields
4729
- %i[created_at enabled id name title updated_at].freeze
4830
+ %i[created_at creator id inherits_status title type updated_at visible_to_clients].freeze
4730
4831
  end
4731
4832
 
4732
4833
  def initialize(data = {})
4733
4834
  @created_at = parse_datetime(data["created_at"])
4734
- @enabled = parse_boolean(data["enabled"])
4835
+ @creator = parse_type(data["creator"], "Person")
4735
4836
  @id = parse_integer(data["id"])
4736
- @name = data["name"]
4837
+ @inherits_status = parse_boolean(data["inherits_status"])
4737
4838
  @title = data["title"]
4839
+ @type = data["type"]
4738
4840
  @updated_at = parse_datetime(data["updated_at"])
4841
+ @visible_to_clients = parse_boolean(data["visible_to_clients"])
4739
4842
  @app_url = data["app_url"]
4843
+ @bookmark_url = data["bookmark_url"]
4740
4844
  @bucket = parse_type(data["bucket"], "RecordingBucket")
4845
+ @enabled = parse_boolean(data["enabled"])
4846
+ @name = data["name"]
4847
+ @parent = parse_type(data["parent"], "RecordingParent")
4741
4848
  @position = parse_integer(data["position"])
4742
4849
  @status = data["status"]
4850
+ @subscription_url = data["subscription_url"]
4743
4851
  @url = data["url"]
4744
4852
  end
4745
4853
 
4746
4854
  def to_h
4747
4855
  {
4748
4856
  "created_at" => @created_at,
4749
- "enabled" => @enabled,
4857
+ "creator" => @creator,
4750
4858
  "id" => @id,
4751
- "name" => @name,
4859
+ "inherits_status" => @inherits_status,
4752
4860
  "title" => @title,
4861
+ "type" => @type,
4753
4862
  "updated_at" => @updated_at,
4863
+ "visible_to_clients" => @visible_to_clients,
4754
4864
  "app_url" => @app_url,
4865
+ "bookmark_url" => @bookmark_url,
4755
4866
  "bucket" => @bucket,
4867
+ "enabled" => @enabled,
4868
+ "name" => @name,
4869
+ "parent" => @parent,
4756
4870
  "position" => @position,
4757
4871
  "status" => @status,
4872
+ "subscription_url" => @subscription_url,
4758
4873
  "url" => @url,
4759
4874
  }.compact
4760
4875
  end
data/lib/basecamp/http.rb CHANGED
@@ -342,7 +342,16 @@ module Basecamp
342
342
  Http.normalize_person_ids(data)
343
343
  data
344
344
  rescue JSON::ParserError => e
345
- raise Basecamp::ApiError.new("Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}")
345
+ # +cause+ carries the parser's own error, not just its message (#750). The
346
+ # message says what happened; the slot is what a caller can act on, and it
347
+ # is the same answer Go reaches through errors.As and Kotlin and Swift
348
+ # through their decodeFailure slot. Passed explicitly rather than left to
349
+ # Ruby's implicit +$!+ chaining because Basecamp::Error defines its own
350
+ # +cause+ reader.
351
+ raise Basecamp::ApiError.new(
352
+ "Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}",
353
+ cause: e
354
+ )
346
355
  end
347
356
 
348
357
  # Extracts the item array from a parsed page body: the body itself for
@@ -493,8 +502,10 @@ module Basecamp
493
502
  # Memoized per-operation retry metadata, keyed by canonical operation ID.
494
503
  # Benign-race memoization: concurrent first loads compute identical values.
495
504
  def self.operation_retry(operation)
505
+ # UTF-8 regardless of process locale — JSON is UTF-8 (RFC 8259), and
506
+ # LC_ALL=C would otherwise read as US-ASCII.
496
507
  @operation_metadata ||= JSON.parse(
497
- File.read(File.join(__dir__, "generated", "metadata.json"))
508
+ File.read(File.join(__dir__, "generated", "metadata.json"), encoding: "UTF-8")
498
509
  ).fetch("operations").freeze
499
510
  @operation_metadata.dig(operation, "retry")
500
511
  end
@@ -785,6 +796,32 @@ module Basecamp
785
796
  end
786
797
 
787
798
  def parse_next_link(link_header)
799
+ # A non-ASCII-compatible tag (UTF-16/32; only a stub or custom adapter
800
+ # produces one — Faraday's Net::HTTP adapter always yields ASCII-8BIT)
801
+ # defeats the byte scan below: "<" is 3C 00 in UTF-16LE, so the ASCII
802
+ # literals never match and a genuinely UTF-16LE header parsed to nil
803
+ # silently, where the pre-#678 character path at least crashed loudly.
804
+ # Transcoding to UTF-8 up front restores parsing for that case, and
805
+ # rebinding the local means the retag at the bottom hands back UTF-8.
806
+ #
807
+ # ASCII bytes MIStagged UTF-16LE split by bytesize parity, and both
808
+ # outcomes are deliberate:
809
+ # - odd (the natural case): invalid UTF-16LE, encode raises, and the
810
+ # fallthrough keeps today's refusal — the scan extracts the URL
811
+ # still mistagged, and Security.same_origin? refuses the follow
812
+ # (ApiError), so no origin-check bypass opens up.
813
+ # - even: valid UTF-16LE by construction, transcodes to CJK mojibake
814
+ # with no rel="next", parses to nil (was: the ApiError above).
815
+ # Garbage-tagged garbage; nil is as good a refusal as a raise.
816
+ unless link_header.nil? || link_header.encoding.ascii_compatible?
817
+ begin
818
+ link_header = link_header.encode(Encoding::UTF_8)
819
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError,
820
+ Encoding::ConverterNotFoundError
821
+ # Not decodable under its claimed encoding — scan the raw bytes.
822
+ end
823
+ end
824
+
788
825
  next_url = nil
789
826
 
790
827
  unless link_header.nil? || link_header.empty?
@@ -39,13 +39,19 @@ module Basecamp
39
39
 
40
40
  ua.scheme.downcase == ub.scheme.downcase &&
41
41
  normalize_host(ua) == normalize_host(ub)
42
- rescue URI::InvalidURIError
42
+ rescue URI::Error
43
+ # URI::Error, not just InvalidURIError: URI.parse("mailto:") raises
44
+ # URI::InvalidComponentError (URI::MailTo demands an opaque part), and a
45
+ # scheme-only URL must be refused, not a crash.
43
46
  false
44
47
  end
45
48
 
46
49
  def self.resolve_url(base, target)
47
50
  URI.join(base, target).to_s
48
- rescue URI::InvalidURIError
51
+ rescue URI::Error
52
+ # URI::Error, not just InvalidURIError: URI.join raises
53
+ # URI::InvalidComponentError on a scheme-only target ("mailto:"),
54
+ # reachable from a poisoned Link header or Location redirect.
49
55
  target
50
56
  end
51
57
 
@@ -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.15.0"
5
+ API_VERSION = "2026-08-11"
6
6
  end
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.15.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-08-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday