seatlayer 0.2.0 → 0.3.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: 0d51f4f004070a397c4ac39e08f183df2c8a72dca70c35ce1ef61042d247b30c
4
- data.tar.gz: a37f6748bf6d656ee1b5daa4902609678bf039992766c9fa9dce7742e4ee47f8
3
+ metadata.gz: 07a3046d14b181d770f7dddf673476c7b0d695d31d725adc042555afa145c144
4
+ data.tar.gz: 5923f05115c9741d3ab20ac8598616351eeac43d74a91b5da0c590275f0105b0
5
5
  SHA512:
6
- metadata.gz: 25dfd25bc366329bb38f52f59522dde781225df50fcfe5b6c523e9b7af3ab54d16aa22b6c6ea022b8e65d5e98b1a8dcbabd04c3716026cc257918c0fa3b9ce73
7
- data.tar.gz: 98af014bd435e3f5ee7233c5b3c8feeb9b4ed950e9df55b5c1be4ec1dc94506657cb83c0149202c9421bbf223efb760ad15fb713ea88797222326b435236f41c
6
+ metadata.gz: d9b16c52a2dad95aab45a2d0d75dad7be100ddb60497b554ce9adc1a86dacdab4e8a6dd273314ba67dd93a7156eebb5984550ebf9a5ad8c2cdc519a356ccf70a
7
+ data.tar.gz: 0fbe576e636045d4cdfc6ff3cffcbd16403fc3e595913832d352bdcebe66cc03fc128dcc2ab7bca3fc490558e099c77838a4fe6a88d54f66305c9a7928ea0ea8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - **Security/reliability:** Mutations now default to a single attempt. Automatic header-replay
6
+ retries are limited to chart create/copy, event create, and workspace create, preventing
7
+ transient failures from duplicating holds or best-available results and from issuing extra
8
+ show-once credentials.
9
+ - Aligned event, inventory, webhook, manage-session, and Designer requests with the generated
10
+ public contract: event metadata, chart-update acknowledgement, trusted hold extension,
11
+ scheduled block release, hold-TTL reset, webhook envelopes and delivery filters, and Designer
12
+ safe-mode/feature-policy fields are now represented directly.
13
+ - Removed unsupported `state` and `cursor` keywords from buyer-access-session listing; the route
14
+ supports only `limit`.
15
+ - Added deterministic transport-contract coverage for stable error-code fallback, HTTP status,
16
+ decoded body and `X-Request-ID` exposure, typed 429 `Retry-After` precedence, non-JSON gateway
17
+ failures, and single-attempt unsafe mutations.
18
+ - Reached all 71 public operation wrappers with raw event-poster upload/removal and the complete
19
+ hosted access-link lifecycle. One-time capability reveals remain single-attempt.
20
+ - Added chart copy/metadata overrides, event-log pagination filters, and explicit-null support for
21
+ buyer-session and workspace fields.
22
+
3
23
  ## 0.2.0 — 2026-08-12
4
24
 
5
25
  - Added the `channels` resource for allocation management, access previews,
data/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # SeatLayer Ruby SDK
2
2
 
3
+ [![CI](https://github.com/seatlayer/seatlayer-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/seatlayer/seatlayer-ruby/actions/workflows/ci.yml)
4
+ [![Gem](https://img.shields.io/gem/v/seatlayer.svg)](https://rubygems.org/gems/seatlayer)
5
+ [![Ruby](https://img.shields.io/badge/Ruby-%E2%89%A53.0-CC342D.svg)](https://www.ruby-lang.org/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-111827.svg)](LICENSE)
7
+
3
8
  Official Ruby server SDK for the [SeatLayer](https://seatlayer.io) reserved-seating API.
4
9
 
5
10
  > **Server-side only.** This gem authenticates with your secret key. Never load it anywhere a
@@ -38,6 +43,9 @@ held = client.inventory.hold_best_available(event["key"], qty: 4)
38
43
  client.inventory.book(event["key"], hold_id: held["holdId"], booking_ref: "order-8842")
39
44
  ```
40
45
 
46
+ Nullable event-create fields distinguish omission from an explicit reset: passing, for example,
47
+ `venue: nil` sends JSON `null`; leaving `venue` out sends no field.
48
+
41
49
  ## Test vs live
42
50
 
43
51
  Keys carry their own mode. `sk_test_…` keys can only touch test-mode events and `sk_live_…` only
@@ -150,11 +158,9 @@ session = client.sessions.create_manage_session(
150
158
  )
151
159
  ```
152
160
 
153
- `capabilities` is **required** by this SDK even though the API defaults it. Omit it at the API level
154
- and you get `event:view`, `event:block`, `event:cancel` and `event:reports` including
155
- `event:cancel`, which unbooks paid seats **and authorises refunds against the organiser's connected
156
- payment gateway**. That is real money, moved by a token you handed to a browser; it should not
157
- arrive by forgetting an argument. Grant the smallest set the page needs.
161
+ `capabilities` is **required** by this SDK even though the raw API safely defaults an omitted list
162
+ to view-only (`event:view`). Keeping the argument required makes browser authority visible at every
163
+ call site. Grant the smallest set the page needs.
158
164
 
159
165
  The full set, all opt-in:
160
166
 
@@ -166,13 +172,27 @@ The full set, all opt-in:
166
172
  | `event:reports` | Read sales and availability reports |
167
173
  | `event:channels:view` | Read sales channels and their allocations |
168
174
  | `event:channels:manage` | Create, pause and archive channels; rotate access links |
175
+ | `event:orders:read` | Read SeatLayer-managed orders |
176
+ | `event:refund` | Refund a SeatLayer-managed order |
177
+ | `event:tickets:send` | Send SeatLayer-managed tickets |
178
+ | `event:door:view` | Read the door list |
179
+ | `event:door:checkin` | Check tickets in and out |
180
+ | `event:boxoffice` | Use the managed box-office surface |
169
181
 
170
182
  The two `event:channels:*` capabilities are **not** in the default — a token minted before sales
171
183
  channels existed must not silently acquire channel authority — so ask for them explicitly if the
172
184
  page manages channels.
173
185
 
186
+ Designer minting returns the API envelope unchanged: read the token and effective safe-mode and
187
+ feature policy under `result["session"]`. Pass `safe_mode_options` only with `mode: "safe"`.
188
+
174
189
  ## Webhooks
175
190
 
191
+ Subscription responses use the wire envelopes exactly: `list` returns `{"subs" => [...]}`,
192
+ `create` returns `{"sub" => ..., "secret" => ...}` (the secret is shown once), and `update`
193
+ returns `{"sub" => ...}`. `SeatLayer::Webhooks::EVENT_NAMES` is the exact eight-name event set;
194
+ delivery history accepts `limit`, `status` (`"ok"` or `"failed"`), and `before`.
195
+
176
196
  Verify every delivery against the **raw** body. Re-encoding a parsed Hash changes the bytes and
177
197
  verification will fail.
178
198
 
@@ -231,15 +251,20 @@ error carries `status`, `code`, `body` and `request_id` — quote the request id
231
251
 
232
252
  ## Reliability
233
253
 
234
- **Retries.** 429, 408 and 5xx are retried with exponential backoff and full jitter; `Retry-After`
235
- wins when the server sends it. 4xx is never retried it will not start succeeding.
254
+ **Retries.** Reads (`GET`/`HEAD`) retry 429, 408 and 5xx with exponential backoff and full jitter;
255
+ `Retry-After` wins when the server sends it. Automatic mutation retries are limited to the four
256
+ operations backed by exact response replay: `charts.create`, `charts.copy`, `events.create`, and
257
+ `workspaces.create`. Other 4xx responses are never retried.
236
258
 
237
- **Idempotency.** Every mutating request carries an `Idempotency-Key`, generated if you do not supply
238
- one, and **reused across retries** so a retried booking cannot become two bookings. Pass your own
239
- order id for end-to-end deduplication:
259
+ **Idempotency.** Those four replay-backed operations carry an `Idempotency-Key`, generated when you
260
+ do not supply one and reused across attempts. Other mutations are single-attempt and receive no
261
+ automatic key. A caller-supplied key is forwarded but does not enable retries. This includes
262
+ inventory holds and bookings, show-once credential or secret creation, unsupported operations, and
263
+ raw `request` mutations. Keep `booking_ref` in the booking body for reconciliation, but handle an
264
+ unknown network outcome explicitly instead of automatically repeating the sale.
240
265
 
241
266
  ```ruby
242
- client.inventory.book(event_key, hold_id: hold_id, idempotency_key: "order-#{order_id}")
267
+ client.events.create(chart_id: chart_id, idempotency_key: "provision-event-#{event_id}")
243
268
  ```
244
269
 
245
270
  ```ruby
@@ -252,7 +277,8 @@ SeatLayer::Client.new(
252
277
 
253
278
  ## Escape hatch
254
279
 
255
- For surface this SDK does not wrap yet same auth, retries, idempotency and error mapping:
280
+ For surface this SDK does not wrap yet, `request` keeps auth and error mapping. Raw reads retain the
281
+ read retry policy; raw mutations are always single-attempt because their replay contract is unknown:
256
282
 
257
283
  ```ruby
258
284
  client.request("POST", "/v1/events/ev_1/some-new-route", body: { "qty" => 2 })
@@ -263,9 +289,9 @@ client.request("POST", "/v1/events/ev_1/some-new-route", body: { "qty" => 2 })
263
289
  | Resource | Methods |
264
290
  | --- | --- |
265
291
  | `charts` | `list` `list_all` `create` `retrieve` `update` `delete` `copy` `archive` `unarchive` `publish` |
266
- | `events` | `list` `list_all` `create` `retrieve` `update` `delete` `update_chart` `close` `reopen` `archive` `retrieve_hold_ttl` `update_hold_ttl` `retrieve_report` `retrieve_log` |
292
+ | `events` | `list` `list_all` `create` `retrieve` `update` `delete` `update_poster` `delete_poster` `update_chart` `close` `reopen` `archive` `retrieve_hold_ttl` `update_hold_ttl` `retrieve_report` `retrieve_log` |
267
293
  | `inventory` | `hold` `hold_best_available` `book_best_available` `extend_hold` `retrieve_hold` `release` `book` `box_office_book` `unbook` `list_bookings` `retrieve_booking` `block` `unblock` `unblock_all` `retrieve_availability` `update_availability` |
268
- | `channels` | `list_channels` `create_channel` `update_channel` `update_assignments` `list_allocation` `retrieve_access_preview` `retrieve_report` `pause` `unpause` `archive` `create_buyer_access_session` `list_buyer_access_sessions` `revoke_buyer_access_session` |
294
+ | `channels` | `list_channels` `create_channel` `update_channel` `update_assignments` `list_allocation` `retrieve_access_preview` `retrieve_report` `pause` `unpause` `archive` `create_buyer_access_session` `list_buyer_access_sessions` `revoke_buyer_access_session` `create_access_link` `list_access_links` `rotate_access_link` `revoke_access_link` |
269
295
  | `sessions` | `create_manage_session` `revoke_manage_session` `create_designer_session` `revoke_designer_session` |
270
296
  | `webhooks` | `list` `create` `update` `delete` `list_deliveries` |
271
297
  | `workspaces` | `list` `create` `retrieve` `update` |
@@ -65,26 +65,64 @@ module SeatLayer
65
65
  # Explicit keywords document each security boundary carried by the token.
66
66
  # rubocop:disable Metrics/ParameterLists
67
67
  def create_buyer_access_session(event_key, include_public:, allowed_origin:, channel_ids: nil,
68
- expires_in_seconds: nil, max_quantity: nil, buyer_ref: nil,
69
- partner_ref: nil, client_request_id: nil, idempotency_key: nil)
68
+ expires_in_seconds: nil, max_quantity: UNSET, buyer_ref: UNSET,
69
+ partner_ref: UNSET, client_request_id: UNSET, idempotency_key: nil)
70
70
  body = compact({ "channelIds" => channel_ids, "includePublic" => include_public,
71
- "allowedOrigin" => allowed_origin, "expiresInSeconds" => expires_in_seconds,
72
- "maxQuantity" => max_quantity, "buyerRef" => buyer_ref,
73
- "partnerRef" => partner_ref, "clientRequestId" => client_request_id })
71
+ "allowedOrigin" => allowed_origin, "expiresInSeconds" => expires_in_seconds })
72
+ body.merge!(supplied({ "maxQuantity" => max_quantity, "buyerRef" => buyer_ref,
73
+ "partnerRef" => partner_ref,
74
+ "clientRequestId" => client_request_id }))
74
75
  @client.post("/v1/events/#{encode(event_key)}/buyer-access-sessions", body,
75
76
  idempotency_key: idempotency_key)
76
77
  end
77
78
  # rubocop:enable Metrics/ParameterLists
78
79
 
79
- def list_buyer_access_sessions(event_key, state: nil, limit: nil, cursor: nil)
80
+ def list_buyer_access_sessions(event_key, limit: nil)
80
81
  @client.get("/v1/events/#{encode(event_key)}/buyer-access-sessions",
81
- compact({ "state" => state, "limit" => limit, "cursor" => cursor }))
82
+ compact({ "limit" => limit }))
82
83
  end
83
84
 
84
85
  def revoke_buyer_access_session(event_key, session_id)
85
86
  @client.delete("/v1/events/#{encode(event_key)}/buyer-access-sessions/#{encode(session_id)}")
86
87
  end
87
88
 
89
+ # The URL and capability in this response are revealed once. Persist them
90
+ # immediately; this mutation is deliberately never retried automatically.
91
+ # rubocop:disable Metrics/ParameterLists
92
+ def create_access_link(event_key, channel_id, label: UNSET, expires_at: nil,
93
+ max_redemptions: nil, max_quantity: nil,
94
+ session_ttl_seconds: nil, include_public: nil, reason: nil,
95
+ idempotency_key: nil)
96
+ body = compact({ "expiresAt" => expires_at, "maxRedemptions" => max_redemptions,
97
+ "maxQuantity" => max_quantity,
98
+ "sessionTtlSeconds" => session_ttl_seconds,
99
+ "includePublic" => include_public, "reason" => reason })
100
+ body.merge!(supplied({ "label" => label }))
101
+ @client.post(path(event_key, "/#{encode(channel_id)}/access-links"), body,
102
+ idempotency_key: idempotency_key)
103
+ end
104
+ # rubocop:enable Metrics/ParameterLists
105
+
106
+ # Status only: the API never returns a previously revealed capability.
107
+ def list_access_links(event_key, channel_id)
108
+ @client.get(path(event_key, "/#{encode(channel_id)}/access-links"))
109
+ end
110
+
111
+ def rotate_access_link(event_key, channel_id, link_id, end_active_sessions:, reason: nil)
112
+ body = compact({ "endActiveSessions" => end_active_sessions, "reason" => reason })
113
+ @client.post(
114
+ path(event_key, "/#{encode(channel_id)}/access-links/#{encode(link_id)}/rotate"), body
115
+ )
116
+ end
117
+
118
+ def revoke_access_link(event_key, channel_id, link_id, end_active_sessions: false, reason: nil)
119
+ query = compact({ "endActiveSessions" => end_active_sessions ? "1" : nil,
120
+ "reason" => reason })
121
+ @client.delete(
122
+ path(event_key, "/#{encode(channel_id)}/access-links/#{encode(link_id)}"), query
123
+ )
124
+ end
125
+
88
126
  private
89
127
 
90
128
  def path(event_key, suffix = "") = "/v1/events/#{encode(event_key)}/channels#{suffix}"
@@ -62,7 +62,8 @@ module SeatLayer
62
62
  URI.encode_www_form_component(segment.to_s).gsub("+", "%20")
63
63
  end
64
64
 
65
- def request(method, path, query: nil, body: nil, idempotency_key: nil)
65
+ def request(method, path, query: nil, body: nil, raw_body: nil, content_type: nil,
66
+ idempotency_key: nil, retry_policy: :none)
66
67
  url = @base_url + path
67
68
  if query
68
69
  pairs = query.compact
@@ -74,56 +75,73 @@ module SeatLayer
74
75
  "Accept" => "application/json",
75
76
  "User-Agent" => "seatlayer-ruby"
76
77
  }
77
- payload = nil
78
- if body
79
- payload = JSON.generate(body)
80
- headers["Content-Type"] = "application/json"
81
- end
78
+ payload = build_payload(body, raw_body, content_type, headers)
82
79
 
83
- # Every mutation carries one. A retried POST that creates a second hold is
84
- # worse than a failed POST, and the caller cannot tell from outside so
85
- # the SDK, which knows it retried, is the right place to guarantee it.
80
+ # Only operations with exact server-side response replay get an automatic
81
+ # key. A caller key on any other mutation is forwarded, but cannot opt that
82
+ # operation into automatic retries.
86
83
  unless %w[GET HEAD].include?(method)
87
- key = idempotency_key || SecureRandom.uuid
88
- self.class.validate_idempotency_key!(key)
89
- headers["Idempotency-Key"] = key
84
+ key = idempotency_key
85
+ key ||= SecureRandom.uuid if retry_policy == :header_replay
86
+ if key
87
+ self.class.validate_idempotency_key!(key)
88
+ headers["Idempotency-Key"] = key
89
+ end
90
90
  end
91
91
 
92
- execute(method, url, headers, payload)
92
+ retry_allowed = %w[GET HEAD].include?(method) || retry_policy == :header_replay
93
+ execute(method, url, headers, payload, retry_allowed: retry_allowed)
93
94
  end
94
95
 
95
96
  def get(path, query = nil)
96
97
  request("GET", path, query: query)
97
98
  end
98
99
 
99
- def post(path, body = nil, idempotency_key: nil)
100
- request("POST", path, body: body, idempotency_key: idempotency_key)
100
+ def post(path, body = nil, idempotency_key: nil, retry_policy: :none)
101
+ request("POST", path, body: body, idempotency_key: idempotency_key, retry_policy: retry_policy)
101
102
  end
102
103
 
103
104
  def put(path, body)
104
105
  request("PUT", path, body: body)
105
106
  end
106
107
 
108
+ def put_raw(path, raw_body, content_type: "application/octet-stream")
109
+ request("PUT", path, raw_body: raw_body, content_type: content_type)
110
+ end
111
+
107
112
  def patch(path, body)
108
113
  request("PATCH", path, body: body)
109
114
  end
110
115
 
111
- def delete(path)
112
- request("DELETE", path)
116
+ def delete(path, query = nil)
117
+ request("DELETE", path, query: query)
113
118
  end
114
119
 
115
120
  private
116
121
 
122
+ def build_payload(body, raw_body, content_type, headers)
123
+ raise ArgumentError, "body and raw_body are mutually exclusive" if !body.nil? && !raw_body.nil?
124
+
125
+ unless raw_body.nil?
126
+ headers["Content-Type"] = content_type || "application/octet-stream"
127
+ return raw_body
128
+ end
129
+ return if body.nil?
130
+
131
+ headers["Content-Type"] = "application/json"
132
+ JSON.generate(body)
133
+ end
134
+
117
135
  # The retry loop, extracted from #request so each piece stays readable: the
118
136
  # public method builds the call, this one decides how many times to make it.
119
- def execute(method, url, headers, payload)
137
+ def execute(method, url, headers, payload, retry_allowed:)
120
138
  last_error = nil
121
139
 
122
140
  @max_retries.times do |attempt|
123
141
  begin
124
142
  response = send_request(method, url, headers, payload)
125
143
  rescue ConnectionError => e
126
- raise e if attempt >= @max_retries - 1
144
+ raise e unless retry_allowed && attempt < @max_retries - 1
127
145
 
128
146
  last_error = e
129
147
  sleep(backoff(attempt, nil))
@@ -136,7 +154,7 @@ module SeatLayer
136
154
  error_body = decode_error_body(response[:body])
137
155
  retry_after = parse_retry_after(response[:headers], error_body)
138
156
 
139
- if retryable?(status) && attempt < @max_retries - 1
157
+ if retry_allowed && retryable?(status) && attempt < @max_retries - 1
140
158
  sleep(backoff(attempt, status == 429 ? retry_after : nil))
141
159
  next
142
160
  end
@@ -62,8 +62,13 @@ module SeatLayer
62
62
  # the checkout window — invoiced sales, a phone order on hold. Releasing
63
63
  # first hands the seats to whoever is racing for them in between. A hold that
64
64
  # is gone, expired, or at its renewal cap answers 409 +cannot_extend+.
65
- def extend_hold(event_key, hold_id, ttl_ms: nil)
66
- @client.post(path(event_key, "/extend"), compact({ "holdId" => hold_id, "ttlMs" => ttl_ms }))
65
+ def extend_hold(event_key, hold_id, ttl_ms: nil, channel_ids: nil,
66
+ ignore_channel_restrictions: nil, reason: nil)
67
+ body = compact({ "holdId" => hold_id, "ttlMs" => ttl_ms,
68
+ "channelIds" => channel_ids,
69
+ "ignoreChannelRestrictions" => ignore_channel_restrictions,
70
+ "reason" => reason })
71
+ @client.post(path(event_key, "/extend"), body)
67
72
  end
68
73
 
69
74
  # Authoritative items and prices. Charge from this, not the browser.
@@ -98,8 +103,9 @@ module SeatLayer
98
103
  end
99
104
 
100
105
  # Hold inventory back from sale (house seats, production holds).
101
- def block(event_key, labels:)
102
- @client.post(path(event_key, "/block"), { "labels" => labels })
106
+ def block(event_key, labels:, release_at: nil)
107
+ @client.post(path(event_key, "/block"),
108
+ compact({ "labels" => labels, "releaseAt" => release_at }))
103
109
  end
104
110
 
105
111
  def unblock(event_key, labels:)
@@ -147,24 +153,29 @@ module SeatLayer
147
153
  # The governing rule: the SDK mints tokens, widgets consume them. Your secret
148
154
  # key never reaches a browser.
149
155
  class Sessions < Resource
150
- CAPABILITIES = ["event:view", "event:block", "event:cancel", "event:reports"].freeze
156
+ CAPABILITIES = [
157
+ "event:view", "event:block", "event:cancel", "event:reports",
158
+ "event:channels:view", "event:channels:manage", "event:orders:read",
159
+ "event:refund", "event:tickets:send", "event:door:view",
160
+ "event:door:checkin", "event:boxoffice"
161
+ ].freeze
151
162
 
152
163
  # Mint a manage-session token for the control room.
153
164
  #
154
- # +capabilities+ is required here even though the API defaults it. That
155
- # default grants all four including event:cancel, which un-books paid
156
- # inventory. Granting the ability to reverse sales by forgetting an argument
157
- # is not a default worth inheriting.
158
- def create_manage_session(event_key, allowed_origin:, capabilities:, expires_in_seconds: nil)
165
+ # The raw API defaults an omitted list to view-only (+event:view+). This SDK
166
+ # still requires an explicit set so browser authority is visible at each call.
167
+ def create_manage_session(event_key, allowed_origin:, capabilities:, expires_in_seconds: nil,
168
+ workspace_id: nil)
159
169
  if capabilities.nil? || capabilities.empty?
160
170
  raise ArgumentError,
161
- "capabilities is required: pass the smallest set the page needs, e.g. " \
162
- '["event:view"]. Omitting it server-side grants event:cancel, ' \
163
- "which can reverse paid bookings."
171
+ 'capabilities is required: pass the smallest set the page needs, e.g. ["event:view"].'
164
172
  end
173
+ unknown = capabilities - CAPABILITIES
174
+ raise ArgumentError, "unsupported manage capabilities: #{unknown.join(", ")}" unless unknown.empty?
165
175
 
166
176
  body = compact({ "allowedOrigin" => allowed_origin, "capabilities" => capabilities,
167
- "expiresInSeconds" => expires_in_seconds })
177
+ "expiresInSeconds" => expires_in_seconds,
178
+ "workspaceId" => workspace_id })
168
179
  @client.post("/v1/events/#{encode(event_key)}/manage-sessions", body)
169
180
  end
170
181
 
@@ -174,13 +185,19 @@ module SeatLayer
174
185
 
175
186
  # Mint a designer token so an organiser can edit a chart inside your own UI.
176
187
  # Requires a chart id that already exists — create or copy one first.
188
+ # Explicit keywords keep each security and feature-policy boundary visible.
189
+ # rubocop:disable Metrics/ParameterLists
177
190
  def create_designer_session(workspace_id:, chart_id:, allowed_origin:,
178
- authority: nil, mode: nil, expires_in_seconds: nil)
191
+ authority: nil, can_publish: nil, mode: nil,
192
+ safe_mode_options: nil, features: nil, expires_in_seconds: nil)
179
193
  body = compact({ "workspaceId" => workspace_id, "chartId" => chart_id,
180
194
  "allowedOrigin" => allowed_origin, "authority" => authority,
181
- "mode" => mode, "expiresInSeconds" => expires_in_seconds })
195
+ "canPublish" => can_publish, "mode" => mode,
196
+ "safeModeOptions" => safe_mode_options, "features" => features,
197
+ "expiresInSeconds" => expires_in_seconds })
182
198
  @client.post("/v1/designer/sessions", body)
183
199
  end
200
+ # rubocop:enable Metrics/ParameterLists
184
201
 
185
202
  def revoke_designer_session(session_id)
186
203
  @client.delete("/v1/designer/sessions/#{encode(session_id)}")
@@ -189,15 +206,23 @@ module SeatLayer
189
206
 
190
207
  # Manage webhook subscriptions. To VERIFY a delivery, see SeatLayer::Webhook.
191
208
  class Webhooks < Resource
209
+ EVENT_NAMES = [
210
+ "seat.booked", "seat.released", "seat.blocked", "hold.expired",
211
+ "hold.created", "hold.extended", "event.created", "event.soldout"
212
+ ].freeze
213
+
192
214
  def list
193
215
  @client.get("/v1/webhooks")
194
216
  end
195
217
 
196
218
  def create(url:, events:)
219
+ validate_events!(events)
197
220
  @client.post("/v1/webhooks", { "url" => url, "events" => events })
198
221
  end
199
222
 
200
- def update(webhook_id, fields)
223
+ def update(webhook_id, url: nil, events: nil, disabled: nil)
224
+ validate_events!(events) unless events.nil?
225
+ fields = compact({ "url" => url, "events" => events, "disabled" => disabled })
201
226
  @client.patch("/v1/webhooks/#{encode(webhook_id)}", fields)
202
227
  end
203
228
 
@@ -205,8 +230,20 @@ module SeatLayer
205
230
  @client.delete("/v1/webhooks/#{encode(webhook_id)}")
206
231
  end
207
232
 
208
- def list_deliveries(webhook_id)
209
- @client.get("/v1/webhooks/#{encode(webhook_id)}/deliveries")
233
+ def list_deliveries(webhook_id, limit: nil, status: nil, before: nil)
234
+ raise ArgumentError, "status must be ok or failed" unless status.nil? || %w[ok failed].include?(status)
235
+
236
+ query = compact({ "limit" => limit, "status" => status, "before" => before })
237
+ @client.get("/v1/webhooks/#{encode(webhook_id)}/deliveries", query)
238
+ end
239
+
240
+ private
241
+
242
+ def validate_events!(events)
243
+ unknown = Array(events) - EVENT_NAMES
244
+ return if !Array(events).empty? && unknown.empty?
245
+
246
+ raise ArgumentError, "events must contain only supported SeatLayer webhook event names"
210
247
  end
211
248
  end
212
249
 
@@ -216,9 +253,12 @@ module SeatLayer
216
253
  @client.get("/v1/workspaces")
217
254
  end
218
255
 
219
- def create(name:, external_ref: nil, idempotency_key: nil)
220
- body = compact({ "name" => name, "externalRef" => external_ref })
221
- @client.post("/v1/workspaces", body, idempotency_key: idempotency_key)
256
+ def create(name:, external_ref: UNSET, idempotency_key: nil)
257
+ body = { "name" => name }
258
+ body.merge!(supplied({ "externalRef" => external_ref }))
259
+ @client.post(
260
+ "/v1/workspaces", body, idempotency_key: idempotency_key, retry_policy: :header_replay
261
+ )
222
262
  end
223
263
 
224
264
  def retrieve(workspace_id)
@@ -3,6 +3,8 @@
3
3
  module SeatLayer
4
4
  # Shared plumbing for the resource namespaces.
5
5
  class Resource
6
+ UNSET = Object.new.freeze
7
+
6
8
  def initialize(client)
7
9
  @client = client
8
10
  end
@@ -15,6 +17,11 @@ module SeatLayer
15
17
  hash.compact
16
18
  end
17
19
 
20
+ # Drop only the private sentinel, preserving nil as explicit JSON null.
21
+ def supplied(hash)
22
+ hash.reject { |_key, value| value.equal?(UNSET) }
23
+ end
24
+
18
25
  def encode(segment)
19
26
  HTTPClient.encode(segment)
20
27
  end
@@ -58,14 +65,16 @@ module SeatLayer
58
65
  def create(name:, doc: nil, external_ref: nil, workspace_id: nil, idempotency_key: nil)
59
66
  body = compact({ "name" => name, "doc" => doc,
60
67
  "externalRef" => external_ref, "workspaceId" => workspace_id })
61
- @client.post("/v1/charts", body, idempotency_key: idempotency_key)
68
+ @client.post(
69
+ "/v1/charts", body, idempotency_key: idempotency_key, retry_policy: :header_replay
70
+ )
62
71
  end
63
72
 
64
73
  def retrieve(chart_id)
65
74
  @client.get("/v1/charts/#{encode(chart_id)}")
66
75
  end
67
76
 
68
- # Replace a chart document.
77
+ # Replace a chart document or update metadata only.
69
78
  #
70
79
  # +expected_updated_at+ is required for optimistic concurrency and is not
71
80
  # optional here either: without it two concurrent writers silently overwrite
@@ -74,8 +83,20 @@ module SeatLayer
74
83
  #
75
84
  # The Designer is the authoring surface. Use this for bulk programmatic edits
76
85
  # and migrations, not for drawing.
77
- def update(chart_id, doc:, expected_updated_at:, name: nil)
78
- body = compact({ "doc" => doc, "expectedUpdatedAt" => expected_updated_at, "name" => name })
86
+ def update(chart_id, doc: UNSET, expected_updated_at: UNSET, name: nil, issues: nil,
87
+ external_ref: UNSET)
88
+ doc_supplied = !doc.equal?(UNSET)
89
+ expected_supplied = !expected_updated_at.equal?(UNSET)
90
+ unless doc_supplied == expected_supplied
91
+ raise ArgumentError, "doc and expected_updated_at must be supplied together"
92
+ end
93
+
94
+ body = compact({ "name" => name, "issues" => issues })
95
+ if doc_supplied
96
+ body["doc"] = doc
97
+ body["expectedUpdatedAt"] = expected_updated_at
98
+ end
99
+ body.merge!(supplied({ "externalRef" => external_ref }))
79
100
  @client.put("/v1/charts/#{encode(chart_id)}", body)
80
101
  end
81
102
 
@@ -84,8 +105,14 @@ module SeatLayer
84
105
  end
85
106
 
86
107
  # Copy a chart — the usual way to provision a venue from a template.
87
- def copy(chart_id, idempotency_key: nil)
88
- @client.post("/v1/charts/#{encode(chart_id)}/duplicate", nil, idempotency_key: idempotency_key)
108
+ def copy(chart_id, idempotency_key: nil, name: nil, external_ref: UNSET,
109
+ workspace_id: nil)
110
+ body = compact({ "name" => name, "workspaceId" => workspace_id })
111
+ body.merge!(supplied({ "externalRef" => external_ref }))
112
+ @client.post(
113
+ "/v1/charts/#{encode(chart_id)}/duplicate", body.empty? ? nil : body,
114
+ idempotency_key: idempotency_key, retry_policy: :header_replay
115
+ )
89
116
  end
90
117
 
91
118
  def archive(chart_id)
@@ -131,13 +158,22 @@ module SeatLayer
131
158
  end
132
159
  end
133
160
 
134
- def create(chart_id:, name: nil, slug: nil, starts_at: nil, venue: nil,
135
- external_ref: nil, currency: nil, idempotency_key: nil)
136
- body = compact({ "chartId" => chart_id, "name" => name, "slug" => slug,
137
- "startsAt" => starts_at, "venue" => venue,
138
- "externalRef" => external_ref, "currency" => currency })
139
- @client.post("/v1/events", body, idempotency_key: idempotency_key)
161
+ # rubocop:disable Metrics/ParameterLists
162
+ def create(chart_id:, name: nil, slug: nil, starts_at: UNSET, venue: UNSET,
163
+ external_ref: UNSET, currency: UNSET, idempotency_key: nil,
164
+ description: UNSET, ends_at: UNSET, timezone: UNSET, locale: UNSET,
165
+ poster_asset_id: UNSET, mode: nil)
166
+ body = compact({ "chartId" => chart_id, "name" => name, "slug" => slug, "mode" => mode })
167
+ body.merge!(supplied({ "startsAt" => starts_at, "venue" => venue,
168
+ "externalRef" => external_ref, "currency" => currency,
169
+ "description" => description, "endsAt" => ends_at,
170
+ "timezone" => timezone, "locale" => locale,
171
+ "posterAssetId" => poster_asset_id }))
172
+ @client.post(
173
+ "/v1/events", body, idempotency_key: idempotency_key, retry_policy: :header_replay
174
+ )
140
175
  end
176
+ # rubocop:enable Metrics/ParameterLists
141
177
 
142
178
  def retrieve(event_key)
143
179
  @client.get("/v1/events/#{encode(event_key)}")
@@ -151,9 +187,20 @@ module SeatLayer
151
187
  @client.delete("/v1/events/#{encode(event_key)}")
152
188
  end
153
189
 
190
+ # Upload raw PNG, JPEG, or WebP bytes (maximum 5 MiB).
191
+ def update_poster(event_key, image, content_type: "application/octet-stream")
192
+ @client.put_raw("/v1/events/#{encode(event_key)}/poster", image, content_type: content_type)
193
+ end
194
+
195
+ def delete_poster(event_key)
196
+ @client.delete("/v1/events/#{encode(event_key)}/poster")
197
+ end
198
+
154
199
  # Move a live event onto the latest published version of its chart.
155
- def update_chart(event_key)
156
- @client.post("/v1/events/#{encode(event_key)}/update-chart")
200
+ def update_chart(event_key, acknowledge_dropped_assignments: nil, reason: nil)
201
+ body = compact({ "acknowledgeDroppedAssignments" => acknowledge_dropped_assignments,
202
+ "reason" => reason })
203
+ @client.post("/v1/events/#{encode(event_key)}/update-chart", body)
157
204
  end
158
205
 
159
206
  # Stop buyer sales. Existing holds keep their TTL.
@@ -174,6 +221,7 @@ module SeatLayer
174
221
  end
175
222
 
176
223
  def update_hold_ttl(event_key, hold_ttl_ms)
224
+ # +nil+ restores the event default and must remain an explicit JSON null.
177
225
  @client.post("/v1/events/#{encode(event_key)}/hold-ttl", { "holdTtlMs" => hold_ttl_ms })
178
226
  end
179
227
 
@@ -181,8 +229,9 @@ module SeatLayer
181
229
  @client.get("/v1/events/#{encode(event_key)}/report")
182
230
  end
183
231
 
184
- def retrieve_log(event_key)
185
- @client.get("/v1/events/#{encode(event_key)}/log")
232
+ def retrieve_log(event_key, limit: nil, before: nil)
233
+ @client.get("/v1/events/#{encode(event_key)}/log",
234
+ compact({ "limit" => limit, "before" => before }))
186
235
  end
187
236
  end
188
237
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SeatLayer
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/seatlayer.rb CHANGED
@@ -46,10 +46,12 @@ module SeatLayer
46
46
  @http.get("/health/ready")
47
47
  end
48
48
 
49
- # Escape hatch for surface this SDK does not wrap yet. Carries the same auth,
50
- # retries, idempotency and error mapping.
51
- def request(method, path, query: nil, body: nil, idempotency_key: nil)
52
- @http.request(method, path, query: query, body: body, idempotency_key: idempotency_key)
49
+ # Escape hatch for surface this SDK does not wrap yet. Reads retain retries;
50
+ # raw mutations are single-attempt because their replay contract is unknown.
51
+ def request(method, path, query: nil, body: nil, raw_body: nil, content_type: nil,
52
+ idempotency_key: nil)
53
+ @http.request(method, path, query: query, body: body, raw_body: raw_body,
54
+ content_type: content_type, idempotency_key: idempotency_key)
53
55
  end
54
56
  end
55
57
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: seatlayer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - SeatLayer