kit-rb 0.1.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.githooks/pre-commit +21 -0
  3. data/.githooks/pre-push +6 -0
  4. data/CHANGELOG.md +83 -0
  5. data/README.md +58 -16
  6. data/docs/DESIGN.md +38 -14
  7. data/docs/TASKS.md +47 -0
  8. data/lib/kit/auth/api_key.rb +7 -0
  9. data/lib/kit/auth/credential.rb +22 -0
  10. data/lib/kit/auth/oauth.rb +7 -8
  11. data/lib/kit/connection.rb +53 -20
  12. data/lib/kit/errors.rb +52 -15
  13. data/lib/kit/oauth/token.rb +9 -0
  14. data/lib/kit/objects/account.rb +44 -3
  15. data/lib/kit/objects/broadcast_click.rb +17 -0
  16. data/lib/kit/objects/broadcast_stats.rb +30 -0
  17. data/lib/kit/objects/bulk_result.rb +39 -0
  18. data/lib/kit/objects/custom_field.rb +4 -2
  19. data/lib/kit/objects/email_stats.rb +25 -0
  20. data/lib/kit/objects/growth_stats.rb +20 -0
  21. data/lib/kit/objects/post.rb +4 -2
  22. data/lib/kit/objects/sequence_email.rb +5 -2
  23. data/lib/kit/objects/subscriber.rb +16 -9
  24. data/lib/kit/objects/subscriber_stats.rb +26 -0
  25. data/lib/kit/objects/tag.rb +5 -3
  26. data/lib/kit/objects/webhook_endpoint.rb +7 -2
  27. data/lib/kit/pagination.rb +47 -3
  28. data/lib/kit/resources/account.rb +9 -8
  29. data/lib/kit/resources/base.rb +46 -4
  30. data/lib/kit/resources/broadcasts.rb +39 -16
  31. data/lib/kit/resources/bulk.rb +18 -20
  32. data/lib/kit/resources/custom_fields.rb +2 -2
  33. data/lib/kit/resources/forms.rb +3 -3
  34. data/lib/kit/resources/posts.rb +1 -1
  35. data/lib/kit/resources/purchases.rb +10 -6
  36. data/lib/kit/resources/sequences.rb +49 -25
  37. data/lib/kit/resources/snippets.rb +13 -8
  38. data/lib/kit/resources/subscribers.rb +24 -11
  39. data/lib/kit/resources/tags.rb +6 -6
  40. data/lib/kit/resources/webhook_endpoints.rb +12 -4
  41. data/lib/kit/resources/webhooks.rb +1 -1
  42. data/lib/kit/version.rb +1 -1
  43. data/lib/kit/webhooks/delivery.rb +61 -0
  44. data/lib/kit/webhooks/events.rb +113 -0
  45. data/lib/kit/webhooks/signature.rb +100 -0
  46. data/lib/kit-rb.rb +10 -1
  47. data/sig/kit-rb.rbs +240 -37
  48. metadata +16 -2
  49. data/lib/kit/objects/raw.rb +0 -12
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa9f06f61a89d938cb719fcbd0cad578e8dd6d6ffc35fcf9b17d356f6a9160b3
4
- data.tar.gz: 6279b4801b2cd8738b11bcc43babad6b8654739683ace8ff86389e040b8a994b
3
+ metadata.gz: a162f45f8175f25dc05e677013062f320dc8e3b494d7fc8e7bd998b9b3a72a1b
4
+ data.tar.gz: cd4c78b1b42fb6019a6013863501c3091ea49019ca7dbfc7551f0c26c8b0633e
5
5
  SHA512:
6
- metadata.gz: 3cc4008cae00b1fe78391e5816b3e19040103c86b5d4feedbc8a03fdce856325e969840f4d568c583f2f5a1bd579f69ffd403c7e515cfca611a3ea0eca24b636
7
- data.tar.gz: 279c04700c82548a16c64637d72269d1ad36a06419228e5ba738e603f98f23983f60183078a79c5a166dee45bc40d5f36a50c4b841a969d020acdaa6bdebf8b7
6
+ metadata.gz: c9c8a1a2e81b6fedce1c815a9cbf2fd02df62aea031c50df3fd06aba94c380164f0290115060f16029617b811ee145efb45b3b95d93803907e96fda9283e8956
7
+ data.tar.gz: 0eb5b408eb466cdf4fd4c19de9d50c7e23ad36d4e743e9c8c920b4523c502abd346dd87e74c8f79a345f341e8bb3a2bfa4378cf16d4e9acfb2bc99bca1b0f073
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Lint the Ruby files that are actually staged so the commit gate matches the
5
+ # commit. RuboCop exits non-zero on any offense, which set -e turns into an
6
+ # aborted commit — no output pattern is parsed, so a singular "1 offense
7
+ # detected" can no longer slip through.
8
+ # (A while-read loop rather than mapfile: macOS ships bash 3.2, which lacks it.)
9
+ staged=()
10
+ while IFS= read -r file; do
11
+ staged+=("$file")
12
+ done < <(
13
+ git diff --cached --name-only --diff-filter=ACM |
14
+ grep -E '(\.(rb|rake|gemspec)|Rakefile|Gemfile)$' || true
15
+ )
16
+
17
+ if [ "${#staged[@]}" -eq 0 ]; then
18
+ exit 0
19
+ fi
20
+
21
+ exec bundle exec rubocop --force-exclusion "${staged[@]}"
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Run the full default gate (spec + rubocop + steep) before anything leaves the
5
+ # machine. exec propagates rake's exit code, so a red gate blocks the push.
6
+ exec bundle exec rake
data/CHANGELOG.md CHANGED
@@ -4,6 +4,83 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres
5
5
  to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [0.3.0] - 2026-09-06
10
+
11
+ The hardening pass (`docs/TASKS.md`): two real-API bugs fixed, the surface
12
+ completed to all 83 documented operations, receiver-side webhook support, and
13
+ contract tests over every envelope, request body and 204.
14
+
15
+ ### Added
16
+ - The two operations 0.2.0 lacked: `subscribers.update_location` (PATCH) and
17
+ `webhook_endpoints.update` (PATCH: rename, change URL, pause/resume with
18
+ `status`, replace `events`). All 83 documented operations now have a method.
19
+ - Incoming webhooks: `Kit::Webhooks::Signature.verify!`/`verify?` (HMAC-SHA256
20
+ `X-Kit-Signature`, replay window, rotation-aware, multiple secrets),
21
+ `Kit::Webhooks::Delivery.from_request`/`parse` with typed `Event`s, and the
22
+ event-name constants `Kit::Webhooks::Events` (28) / `LegacyEvents` (15, with
23
+ `REQUIRED_PARAM`).
24
+ - `Kit::Objects::WebhookEndpoint#secret` — the signing secret Kit returns only on
25
+ `create` and `rotate_secret`.
26
+ - `Pagination#total_count` / `Collection#total_count` (send
27
+ `include_total_count: true`); `Collection#size`, `#length`, `#empty?`, `#[]`
28
+ and a compact `#inspect`.
29
+ - Response fields the spec declares: `Account#timezone`/`#plan` (typed) and
30
+ `#sending_addresses`; `Post#content`; `SequenceEmail#content`;
31
+ `Subscriber#added_at`/`#tagged_at`/`#referrer`/`#referrer_utm_parameters`/
32
+ `#attribution`/`#tags`/`#tag_names`/`#tag_ids`/`#stats`; `Tag#tagged_at`;
33
+ `CustomField#created_at`.
34
+ - `sequences.get`/`sequences.email` accept `include: "stats"`;
35
+ `subscribers.stats` accepts `email_sent_after`/`email_sent_before`.
36
+ - Error classes: `ConflictError` (409), `PayloadTooLargeError` (413),
37
+ `UnexpectedResponseError` (a 2xx whose body is not the documented shape),
38
+ `TransportError` with `TimeoutError` and `ConnectionError` (the http.rb
39
+ exception is kept as `cause`). `APIError#method`/`#path`; messages now read
40
+ `"GET /v4/subscribers/1 failed with status 404: ..."`.
41
+ - `Connection#request_with_status`.
42
+
43
+ ### Changed
44
+ - **Breaking:** the `bulk` methods return `Kit::Objects::BulkResult`
45
+ (`items`, typed `failures`, `async?` for a 202) instead of the raw Hash.
46
+ - **Breaking:** `broadcasts.create/update`, `sequences.create/update`,
47
+ `sequences.create_email/update_email`, `snippets.create/update` and
48
+ `purchases.create` take explicit keyword arguments (the fields the spec
49
+ documents) instead of `**attributes`; an unknown field raises
50
+ `ArgumentError`. Fields not passed are omitted; an explicit `nil` is sent,
51
+ which Kit uses to clear `send_days`/`email_template_id`/`send_at`.
52
+ - **Breaking:** `subscribers.unsubscribe` returns `nil` (the API answers 204).
53
+ - Retries: `Retry-After` is capped at `max_backoff`; a 5xx or transport
54
+ failure is retried only for idempotent verbs (never a POST); a 429 is still
55
+ retried for every verb.
56
+ - Follow-up page requests drop `include_total_count` and any `before` cursor.
57
+ - `Client`, `Configuration`, `Connection`, `Auth::*` and `OAuth::Token`
58
+ mask credentials in `#inspect`.
59
+ - Path ids are percent-encoded; a nil/blank id raises `ArgumentError`.
60
+ - One http.rb client is built per `Connection` instead of per request.
61
+
62
+ ### Fixed
63
+ - `subscribers.unsubscribe` raised `NoMethodError` against the real API (204,
64
+ no body).
65
+ - `webhook_endpoints.create`/`rotate_secret` silently discarded the one-time
66
+ signing secret.
67
+ - A response with an unexpected shape raised `KeyError`/`NoMethodError`
68
+ instead of a `Kit::Error`.
69
+ - The error-mapping specs really slept through retries (60 s per CI run).
70
+
71
+ ## [0.2.0] - 2026-09-04
72
+
73
+ ### Changed
74
+ - **Breaking:** the stats and analytics endpoints now return typed value objects
75
+ instead of raw hashes, matching every other resource:
76
+ `broadcasts.stats`/`stats_list` → `Objects::BroadcastStats` (the nested metrics
77
+ flattened), `broadcasts.clicks` → `Collection[Objects::BroadcastClick]`,
78
+ `subscribers.stats` → `Objects::SubscriberStats`, `account.email_stats` →
79
+ `Objects::EmailStats`, and `account.growth_stats` → `Objects::GrowthStats`.
80
+
81
+ ### Removed
82
+ - `Objects::Raw` (the identity used by the old raw-hash stats returns).
83
+
7
84
  ## [0.1.0] - 2026-09-04
8
85
 
9
86
  First feature-complete release: the entire Kit API v4 surface — all 81 documented
@@ -45,3 +122,9 @@ vertical slice. Not yet feature-complete — resources land in 0.1.0 per docs/DE
45
122
  - Cursor pagination engine (`Kit::Collection#auto_paging_each`) and automatic
46
123
  429/5xx retry with backoff.
47
124
  - RBS signatures for the public surface.
125
+
126
+ [Unreleased]: https://github.com/linyiru/kit-rb/compare/v0.3.0...HEAD
127
+ [0.3.0]: https://github.com/linyiru/kit-rb/compare/v0.2.0...v0.3.0
128
+ [0.2.0]: https://github.com/linyiru/kit-rb/compare/v0.1.0...v0.2.0
129
+ [0.1.0]: https://github.com/linyiru/kit-rb/compare/7ce3329...v0.1.0
130
+ [0.0.0]: https://rubygems.org/gems/kit-rb/versions/0.0.0
data/README.md CHANGED
@@ -4,9 +4,9 @@ A modern, fully-typed Ruby client for the **Kit** (formerly ConvertKit) **API v4
4
4
 
5
5
  The gem is named `kit-rb`; the public namespace is the clean `Kit`.
6
6
 
7
- > Status: the full v4 surface is implemented — every one of the 81 documented
8
- > operations across all resources, verified against the vendored OpenAPI
9
- > document. See [`docs/DESIGN.md`](docs/DESIGN.md).
7
+ > Status: the full v4 surface is implemented — every one of the 83 documented
8
+ > operations across all resources, pinned to the vendored OpenAPI document by
9
+ > contract tests. See [`docs/DESIGN.md`](docs/DESIGN.md).
10
10
 
11
11
  ## Install
12
12
 
@@ -31,21 +31,25 @@ info = client.account.get # => Kit::Objects::AccountInfo
31
31
  info.account.plan_type # => "creator_pro"
32
32
  ```
33
33
 
34
- Responses are immutable `Data` value objects. Errors are typed:
34
+ Responses are immutable `Data` value objects. Errors are typed, and every one
35
+ is a `Kit::Error`:
35
36
 
36
37
  ```ruby
37
38
  begin
38
- client.account.get
39
- rescue Kit::AuthenticationError => e # 401
40
- warn e.status # => 401
39
+ client.subscribers.get(42)
40
+ rescue Kit::NotFoundError => e # 404 — also 401/403/409/413/422 classes
41
+ warn e.message # => "GET /v4/subscribers/42 failed with status 404: ..."
41
42
  rescue Kit::RateLimitError => e # 429 — honours Retry-After
42
43
  sleep e.retry_after
43
- rescue Kit::APIError => e # any other non-2xx
44
- warn e.errors # => ["..."] from Kit's body
44
+ rescue Kit::APIError => e # any other non-2xx: e.status, e.errors, e.method, e.path
45
+ rescue Kit::TimeoutError, Kit::ConnectionError => e # never got a response (< Kit::TransportError)
46
+ rescue Kit::UnexpectedResponseError => e # 2xx whose body is not the documented shape
45
47
  end
46
48
  ```
47
49
 
48
- Transient failures (429 and 5xx) are retried automatically with backoff.
50
+ A 429 is retried for every request (with `Retry-After`, capped at
51
+ `max_backoff`); 5xx and transport failures are retried only for idempotent
52
+ verbs, so a POST is never replayed. Credentials are masked in `#inspect`.
49
53
 
50
54
  ## Resources
51
55
 
@@ -56,15 +60,44 @@ click reports), `email_templates`, `segments`, `posts`, `snippets`, `purchases`,
56
60
 
57
61
  ### Pagination
58
62
 
59
- List endpoints return a `Kit::Collection` — `Enumerable` over the current page,
60
- with lazy cursor following:
63
+ List endpoints return a `Kit::Collection` — `Enumerable` over the current page
64
+ (`size`, `empty?`, `[]`), with lazy cursor following:
61
65
 
62
66
  ```ruby
63
67
  client.subscribers.list.each { |s| ... } # current page
64
68
  client.subscribers.list.auto_paging_each { |s| ... } # every page, lazily
65
- client.subscribers.list(status: "active", per_page: 100)
69
+ page = client.subscribers.list(status: "active", per_page: 100, include_total_count: true)
70
+ page.total_count # => 1234 (first page only, as Kit asks)
66
71
  ```
67
72
 
73
+ ### Bulk
74
+
75
+ The `bulk` endpoints (OAuth only) return a `Kit::Objects::BulkResult`:
76
+
77
+ ```ruby
78
+ result = client.bulk.create_tags([{ name: "vip" }, { name: "" }])
79
+ result.async? # true when Kit queued the batch (202) and will POST to callback_url
80
+ result.items # the affected records (raw Hashes; shape varies per endpoint)
81
+ result.failures # => [#<BulkFailure item={"name"=>""} errors=["Name can't be blank"]>]
82
+ ```
83
+
84
+ ### Receiving webhooks
85
+
86
+ Endpoints created with `client.webhook_endpoints.create` return their signing
87
+ `secret` once. Verify and parse each delivery with it:
88
+
89
+ ```ruby
90
+ delivery = Kit::Webhooks::Delivery.from_request(
91
+ request.raw_post, request.headers["X-Kit-Signature"], secret: ENV.fetch("KIT_WEBHOOK_SECRET")
92
+ ) # raises Kit::Webhooks::SignatureError
93
+ delivery.events.each { |e| handle(e.type, e.data) unless seen?(e.id) }
94
+
95
+ Kit::Webhooks::Events::SUBSCRIBER_TAG_ADDED # => "subscriber.tag_added" (all 28 listed)
96
+ ```
97
+
98
+ Signatures are HMAC-SHA256 over `"#{t}.#{raw_body}"` with a 300 s replay window;
99
+ both secrets are accepted during a rotation.
100
+
68
101
  ### OAuth 2.0
69
102
 
70
103
  ```ruby
@@ -88,14 +121,18 @@ rejects it on the resource endpoints — account access needs the consent flow).
88
121
  The suite is layered:
89
122
 
90
123
  - **Unit** — every method against WebMock stubs.
91
- - **Contract** — each list resource is pinned to the vendored OpenAPI document,
92
- so reading the wrong response envelope fails automatically.
124
+ - **Contract** — every list and single-object operation, every create/update
125
+ request body, and every 204 response is pinned to the vendored OpenAPI
126
+ document, so reading the wrong envelope, sending an undocumented field, or
127
+ parsing a no-content response fails automatically. A weekly workflow diffs
128
+ the vendored document against Kit's and fails on drift.
93
129
  - **Integration** — real recorded responses (VCR cassettes, secrets scrubbed)
94
130
  replayed in CI, proving the live shapes still parse into our value objects.
95
131
  - **Smoke** — `rake smoke` hits every read endpoint live (needs `KIT_API_KEY`).
96
132
  - **E2E** — an opt-in (`KIT_E2E=1`) create→update→list→delete lifecycle that
97
133
  cleans up after itself.
98
- - **Types** — full RBS signatures, checked with Steep.
134
+ - **Types** — full RBS signatures (explicit keywords on every create/update),
135
+ checked with Steep. Line and branch coverage are enforced at 90%.
99
136
 
100
137
  ```sh
101
138
  bin/setup
@@ -103,6 +140,11 @@ bundle exec rake # spec + rubocop + steep
103
140
  bundle exec rake smoke # live read-only smoke (needs a key)
104
141
  ```
105
142
 
143
+ `bin/setup` points `core.hooksPath` at `.githooks/`, so a **pre-commit** hook
144
+ runs RuboCop on staged Ruby files and a **pre-push** hook runs the full
145
+ `bundle exec rake` gate. Both rely on exit codes, not parsed output — a red gate
146
+ cannot be committed or pushed.
147
+
106
148
  ## License
107
149
 
108
150
  MIT.
data/docs/DESIGN.md CHANGED
@@ -3,6 +3,11 @@
3
3
  A modern, fully-typed Ruby client for the **Kit** (formerly ConvertKit) **API v4**.
4
4
  The existing Ruby gems all stop at API v3/v2; kit-rb targets v4 to a high bar.
5
5
 
6
+ > **Status: shipped.** All phases below are complete. v0.2.0 is on RubyGems;
7
+ > the post-0.2.0 hardening pass (`docs/TASKS.md`) completed the surface to all
8
+ > 83 documented operations, each pinned to the vendored OpenAPI document by
9
+ > contract tests.
10
+
6
11
  ## Locked decisions
7
12
 
8
13
  | area | choice | why |
@@ -19,7 +24,7 @@ The existing Ruby gems all stop at API v3/v2; kit-rb targets v4 to a high bar.
19
24
  Kit::Client # entry: picks an auth strategy, holds one Connection
20
25
  ├─ Kit::Configuration # immutable; validates exactly-one-credential
21
26
  ├─ Kit::Auth::ApiKey # X-Kit-Api-Key header
22
- ├─ Kit::Auth::OAuth # Bearer; authorize/refresh/PKCE land in P1
27
+ ├─ Kit::Auth::OAuth # Bearer; authorize/exchange/refresh/PKCE/revoke/client_credentials
23
28
  ├─ Kit::Connection # http.rb transport, JSON, auth injection, error mapping
24
29
  ├─ Kit::Error (tree) # typed exceptions mapped from HTTP status
25
30
  ├─ Kit::Objects::* # Data value objects, `.from(hash)` constructors
@@ -27,24 +32,43 @@ Kit::Client # entry: picks an auth strategy, holds one Connection
27
32
  ```
28
33
 
29
34
  Facts pinned from the OpenAPI spec (`developers.kit.com/api-reference/v4.json`,
30
- OpenAPI 3.0.3, "Kit API 4.0", host `https://api.kit.com`, 52 paths): API-key
35
+ OpenAPI 3.0.3, "Kit API 4.0", host `https://api.kit.com`, 52 paths / 83 operations,
36
+ vendored to `spec/support/kit-v4.openapi.json`): API-key
31
37
  header `X-Kit-Api-Key`; OAuth authorize/token at `/v4/oauth/*`, scopes read/write;
32
38
  rate limits 120/60s (key) and 600/60s (OAuth); cursor pagination (`after`/`before`
33
39
  + `per_page`, response carries a `pagination` object).
34
40
 
35
- ## Phases
41
+ ## Phases — all complete ✅
36
42
 
37
- - **P0 — foundations (this).** Gem skeleton on the clean `Kit` namespace, gates
43
+ - **P0 — foundations. ✅** Gem skeleton on the clean `Kit` namespace, gates
38
44
  (rspec + rubocop + steep) green in CI (Ruby 3.2–3.4), and a walking-skeleton
39
45
  vertical slice: `GET /v4/account` end to end (auth → request → typed error →
40
46
  `Data` object) with full spec coverage.
41
- - **P1 — core, by hand.** Flesh out the transport: cursor auto-pagination
42
- (lazy Enumerator), 429 rate-limit-aware retry with backoff, the full OAuth
43
- authorization-code grant + refresh + PKCE, and instrumentation hooks.
44
- - **P2 resources, spec-driven + local models.** One class + objects + specs per
45
- resource group (subscribers, tags, custom fields, forms, sequences, broadcasts,
46
- purchases, webhooks, email templates, segments, snippets), generated against
47
- the OpenAPI spec via `forge`, gated by `rspec`/`steep`.
48
- - **P3 quality.** Contract tests against the OpenAPI spec, edge cases
49
- (pagination tail, nulls, large payloads), coverage floor.
50
- - **P4 DX & release.** YARD docs, examples, signed gem, release automation.
47
+ - **P1 — core, by hand. ✅** Cursor auto-pagination (lazy Enumerator, POST-based
48
+ lists supported), 429/5xx retry with backoff, and the full OAuth suite:
49
+ authorization-code grant, single-use refresh, PKCE (S256), RFC 7009 revocation,
50
+ and the client_credentials grant.
51
+ - **P2 resources, spec-driven + local models. ✅** Every resource group shipped
52
+ with objects + specs — subscribers, tags, custom fields, forms, sequences (+
53
+ emails), broadcasts (+ stats/clicks), purchases, webhooks, webhook endpoints,
54
+ email templates, segments, posts, snippets, account extras, and bulk. Early
55
+ batches were dogfooded through `forge` against a local model on mbp; that
56
+ surfaced the `Base#one`/`#collection` abstractions, after which the surface was
57
+ completed by hand. 81 operations shipped in 0.2.0; the two PATCH operations
58
+ landed in the hardening pass. **All 83 operations covered**, pinned by the
59
+ contract specs.
60
+ - **P3 — quality. ✅** An OpenAPI list-envelope contract test (each list resource
61
+ pinned to the vendored spec), plus VCR integration cassettes (secrets scrubbed)
62
+ replayed in CI, `rake smoke` (live read-only), and an opt-in e2e lifecycle.
63
+ 99%+ line coverage; full RBS checked by Steep.
64
+ - **P4 — DX & release. ✅** README with usage/OAuth/testing docs, `rubygems_mfa_
65
+ required`, and a GitHub Actions **OIDC Trusted Publishing** workflow (tag `v*`
66
+ → gated release, no stored key). v0.1.0 and v0.2.0 published this way.
67
+ Deferred (optional): a hosted YARD site and cryptographic gem signing.
68
+ - **P5 — hardening (2026-09-06). ✅** A full audit against the OpenAPI document
69
+ and the live docs, tracked row by row in `docs/TASKS.md`: two real-API bugs
70
+ (204 unsubscribe, dropped webhook secret), safety (credential masking, path
71
+ id validation, no POST replay, Retry-After cap), the missing PATCH
72
+ operations and response fields, incoming-webhook verification, typed bulk
73
+ results and transport errors, explicit keyword bodies with tightened RBS,
74
+ and contract tests over object envelopes, request bodies and 204s.
data/docs/TASKS.md ADDED
@@ -0,0 +1,47 @@
1
+ # Tasks
2
+
3
+ Progress tracker for the post-0.2.0 hardening pass. Each row lands as one atomic
4
+ commit with its own specs; the row is ticked in the same commit. Findings come
5
+ from the 2026-09-04 audit of the gem against the vendored OpenAPI document
6
+ (`spec/support/kit-v4.openapi.json`, 52 paths / 83 operations) and the live docs.
7
+
8
+ Legend: `[ ]` todo · `[x]` done (commit noted).
9
+
10
+ ## P0 — breaks against the real API
11
+
12
+ - [x] `subscribers.unsubscribe` calls `one(...)` on a 204 no-body response → `NoMethodError`. (`fix: subscribers.unsubscribe handles the 204 no-content response`)
13
+ - [x] `WebhookEndpoint` drops `secret`, the only time Kit returns the signing secret (`create`, `rotate_secret`). (`fix: WebhookEndpoint carries the one-time signing secret`)
14
+ - [x] `Retry-After` on 429 is honoured without the `max_backoff` cap (a 30 s header blocks the caller for 60 s). (`fix: cap Retry-After at max_backoff`)
15
+ - [x] Error-mapping specs really sleep: the 429 example alone costs 60 s per CI run. (`test: stop error-mapping specs from really sleeping`)
16
+ - [x] Unexpected response shapes surface as `KeyError` / `NoMethodError` instead of a `Kit::Error`. (`fix: raise Kit::UnexpectedResponseError on drifted 2xx bodies`)
17
+ - [x] Path ids are interpolated unescaped and unvalidated (`get("1/unsubscribe?x")`, `get(nil)`). (`fix: validate and percent-encode path ids`)
18
+ - [x] Non-idempotent POSTs are retried on 5xx (duplicate creates). (`fix: do not replay POSTs after a 5xx`)
19
+
20
+ ## P1 — security / correctness
21
+
22
+ - [x] `Client`, `Configuration`, `Auth::*`, `OAuth::Token` `#inspect` print the credential in plaintext. (`fix: mask credentials in inspect output`)
23
+ - [x] `total_count` is discarded by `Pagination.from`; auto-paging resends `include_total_count` on every page; a `before:` param leaks into `after:` follow-ups. (`feat: surface total_count and fix follow-up page params`)
24
+ - [x] No error classes for 409 (rotate_secret conflict) and 413 (bulk quota). (`feat: typed errors for 409 and 413`)
25
+ - [x] 202 (async bulk) indistinguishable from 200; bulk methods return raw Hashes. (`feat: typed BulkResult with async (202) detection`)
26
+ - [x] Error messages omit the request method and path. (`feat: name the failed request in APIError messages`)
27
+
28
+ ## P2 — API coverage
29
+
30
+ - [x] `PATCH /v4/subscribers/{id}/location` (`subscribers.update_location`). (`feat: subscribers.update_location and stats date window`)
31
+ - [x] `PATCH /v4/webhook_endpoints/{id}` (`webhook_endpoints.update`). (`feat: webhook_endpoints.update`)
32
+ - [x] `sequences.get` / `sequences.email` cannot send `include=stats`; `subscribers.stats` cannot send `email_sent_after/before`. (`feat: include=stats on sequences.get and sequences.email`, `feat: subscribers.update_location and stats date window`)
33
+ - [x] Missing response fields: `Account` (`timezone`, `plan`, `sending_addresses`), `Post#content`, `SequenceEmail#content`, `Subscriber` (`added_at`, `referrer`, `referrer_utm_parameters`, `tagged_at`, `attribution`, `tags`), `Tag#tagged_at`, `CustomField#created_at`. (`feat: complete the value objects against the spec's response schemas`)
34
+ - [x] Incoming webhooks: `X-Kit-Signature` HMAC-SHA256 verification and delivery-envelope parsing. (`feat: verify and parse incoming webhook deliveries`)
35
+ - [x] Webhook event-name constants. (`feat: webhook event-name constants`)
36
+
37
+ ## P3 — engineering quality / DX
38
+
39
+ - [x] `Collection` lacks `size`, `empty?`, `[]`, and a readable `inspect`. (`feat: Collection#size, #empty?, #[] and a readable inspect`)
40
+ - [x] Transport errors (timeouts, connection refused) collapse into the generic `Kit::Error`. (`feat: typed transport errors, retried for idempotent requests`)
41
+ - [x] One `HTTP::Client` per request: no persistent connections. Client is now built once per Connection; true keep-alive (`HTTP.persistent`) is deliberately not used because it is not thread-safe while `Kit::Client` is documented as shareable. (`perf: build the http.rb client once per Connection`)
42
+ - [x] Stale P0/P1 comments in `auth/oauth.rb`, `resources/account.rb`, `sig/kit-rb.rbs`; unused `Auth::OAuth::*_URL` constants; template comments in the gemspec. (`chore: drop stale phase comments, dead constants, and gemspec boilerplate`)
43
+ - [x] README / DESIGN.md claim 81 operations; the spec has 83. (`docs: document the hardening pass in README, DESIGN and CHANGELOG`)
44
+ - [x] CI matrix lacks Ruby 3.5; no Dependabot; no branch coverage. (`ci: test on Ruby 3.5, add Dependabot, enforce branch coverage`)
45
+ - [x] RBS: 22 methods take `**untyped`; tighten the fixed-key bodies (broadcasts, sequences, sequence emails, snippets, purchases). List query params stay `**params`. (`refactor: explicit keyword bodies for create/update methods`)
46
+ - [x] OpenAPI contract test covers only list envelopes; extend to single-object envelopes and request bodies. (`test: contract-check object envelopes, request bodies, and 204s against the spec`)
47
+ - [x] `rake contract:fetch` is manual; add a scheduled drift check. (`ci: weekly OpenAPI drift check`)
@@ -19,6 +19,13 @@ module Kit
19
19
  def headers
20
20
  { HEADER => @key }
21
21
  end
22
+
23
+ # Never print the key: a client or config that ends up in a log line or an
24
+ # exception message must not leak the credential.
25
+ def inspect
26
+ "#<#{self.class.name} key=#{Credential.mask(@key)}>"
27
+ end
28
+ alias to_s inspect
22
29
  end
23
30
  end
24
31
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ module Auth
5
+ # Renders a secret for #inspect output: the last four characters behind a
6
+ # fixed-width mask, so two clients can be told apart in a log without the
7
+ # log ever containing a usable credential. Short secrets are fully masked.
8
+ module Credential
9
+ MASK = "****"
10
+ VISIBLE = 4
11
+
12
+ def self.mask(secret)
13
+ return "nil" if secret.nil?
14
+
15
+ value = secret.to_s
16
+ return MASK if value.length <= VISIBLE * 2
17
+
18
+ "#{MASK}#{value[-VISIBLE..]}"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -5,15 +5,9 @@ module Kit
5
5
  # OAuth 2.0 bearer-token authentication: sends `Authorization: Bearer <token>`.
6
6
  #
7
7
  # Rate limit is 600 requests / 60s, and OAuth is required for the bulk and
8
- # purchase-creation endpoints.
9
- #
10
- # P0 scope: carry an already-obtained access token. The authorization-code
11
- # grant (authorize/token URLs at /v4/oauth/*), refresh, and PKCE helper land
12
- # in P1 — this class is the seam they plug into.
8
+ # purchase-creation endpoints. Obtaining and refreshing tokens is
9
+ # Kit::OAuth::Client's job; this class only carries an access token.
13
10
  class OAuth
14
- AUTHORIZE_URL = "https://api.kit.com/v4/oauth/authorize"
15
- TOKEN_URL = "https://api.kit.com/v4/oauth/token"
16
-
17
11
  def initialize(access_token)
18
12
  raise ConfigurationError, "OAuth access token cannot be blank" if access_token.nil? || access_token.empty?
19
13
 
@@ -23,6 +17,11 @@ module Kit
23
17
  def headers
24
18
  { "Authorization" => "Bearer #{@access_token}" }
25
19
  end
20
+
21
+ def inspect
22
+ "#<#{self.class.name} access_token=#{Credential.mask(@access_token)}>"
23
+ end
24
+ alias to_s inspect
26
25
  end
27
26
  end
28
27
  end
@@ -10,45 +10,74 @@ module Kit
10
10
  # Resources talk to the API only through this.
11
11
  class Connection
12
12
  JSON_TYPE = "application/json"
13
- RETRYABLE = [RateLimitError, ServerError].freeze
13
+ RETRYABLE = [RateLimitError, ServerError, TransportError].freeze
14
+ # Verbs safe to replay after a 5xx: the server may have applied the request
15
+ # before failing, and replaying these cannot create a second resource.
16
+ IDEMPOTENT = %i[get head put delete].freeze
14
17
 
15
18
  def initialize(config)
16
19
  @config = config
20
+ # Built once: an http.rb client is an immutable options chain, safe to
21
+ # share across threads, and each request still opens its own socket.
22
+ # (HTTP.persistent would keep one socket alive but is not thread-safe,
23
+ # and a Client is documented as shareable — so it is not used.)
24
+ @client = HTTP
25
+ .headers(default_headers)
26
+ .timeout(connect: @config.open_timeout, read: @config.read_timeout)
17
27
  end
18
28
 
19
29
  # Issues a request and returns the parsed JSON body (a Hash) on success.
20
30
  # 429s honour `Retry-After`; 5xx use exponential backoff with jitter; both
21
31
  # give up after `config.max_retries` and re-raise the typed error.
22
32
  #
33
+ # A 429 is retried for every verb (Kit rejected the request, so nothing was
34
+ # applied). A 5xx or a transport failure (timeout, dropped connection) is
35
+ # retried only for idempotent verbs: a POST that created a subscriber
36
+ # before the gateway failed would be created twice.
37
+ #
23
38
  # @param method [Symbol] :get, :post, :put, :delete
24
39
  # @param path [String] e.g. "/v4/account" (leading slash, no host)
25
40
  # @param params [Hash] query string params
26
41
  # @param body [Hash, nil] JSON request body
27
42
  def request(method, path, params: {}, body: nil)
43
+ request_with_status(method, path, params: params, body: body).last
44
+ end
45
+
46
+ # As #request, but returns [status, body] for the callers that must tell a
47
+ # 200 (applied now) from a 202 (queued; the bulk endpoints).
48
+ def request_with_status(method, path, params: {}, body: nil)
28
49
  attempt = 0
29
50
  begin
30
- handle(perform(method, path, params, body))
51
+ handle(perform(method, path, params, body), method, path)
31
52
  rescue *RETRYABLE => e
32
53
  attempt += 1
33
- raise if attempt > @config.max_retries
54
+ raise if attempt > @config.max_retries || !retryable?(method, e)
34
55
 
35
56
  backoff_sleep(backoff_for(e, attempt))
36
57
  retry
37
58
  end
38
59
  end
39
60
 
61
+ # The memoised http.rb client carries the auth header, so the default
62
+ # inspect would print the credential.
63
+ def inspect
64
+ "#<#{self.class.name} base_url=#{@config.base_url.inspect} auth=#{@config.auth.inspect}>"
65
+ end
66
+
40
67
  private
41
68
 
42
- def perform(method, path, params, body)
43
- client.request(method, "#{@config.base_url}#{path}", params: params, json: body)
44
- rescue HTTP::Error => e
45
- raise Error, "HTTP transport error: #{e.message}"
69
+ def retryable?(method, error)
70
+ error.is_a?(RateLimitError) || IDEMPOTENT.include?(method)
46
71
  end
47
72
 
48
- def client
49
- HTTP
50
- .headers(default_headers)
51
- .timeout(connect: @config.open_timeout, read: @config.read_timeout)
73
+ def perform(method, path, params, body)
74
+ @client.request(method, "#{@config.base_url}#{path}", params: params, json: body)
75
+ rescue HTTP::TimeoutError => e
76
+ raise TimeoutError, "#{method.to_s.upcase} #{path} timed out: #{e.message}"
77
+ rescue HTTP::ConnectionError => e
78
+ raise ConnectionError, "#{method.to_s.upcase} #{path} could not connect: #{e.message}"
79
+ rescue HTTP::Error => e
80
+ raise TransportError, "#{method.to_s.upcase} #{path} failed in transport: #{e.message}"
52
81
  end
53
82
 
54
83
  def default_headers
@@ -59,12 +88,12 @@ module Kit
59
88
  }.merge(@config.auth.headers)
60
89
  end
61
90
 
62
- def handle(response)
91
+ def handle(response, method, path)
63
92
  status = response.status.to_i
64
93
  parsed = parse(response)
65
- return parsed if (200..299).cover?(status)
94
+ return [status, parsed] if (200..299).cover?(status)
66
95
 
67
- raise error_for(status, parsed, response)
96
+ raise error_for(status, parsed, response, method, path)
68
97
  end
69
98
 
70
99
  def parse(response)
@@ -76,21 +105,25 @@ module Kit
76
105
  raw
77
106
  end
78
107
 
79
- def error_for(status, body, response)
108
+ def error_for(status, body, response, method, path)
80
109
  klass = Error.class_for(status)
81
110
  if klass == RateLimitError
82
- klass.new(status: status, body: body, response: response,
111
+ klass.new(status: status, body: body, response: response, method: method, path: path,
83
112
  retry_after: response.headers["Retry-After"]&.to_i)
84
113
  else
85
- klass.new(status: status, body: body, response: response)
114
+ klass.new(status: status, body: body, response: response, method: method, path: path)
86
115
  end
87
116
  end
88
117
 
89
118
  # Seconds to wait before the next attempt: the server's Retry-After when it
90
- # sent one (429), else exponential backoff (base * 2^(n-1)) with jitter,
91
- # capped at config.max_backoff.
119
+ # sent a usable one (429), else exponential backoff (base * 2^(n-1)) with
120
+ # jitter. Both are capped at config.max_backoff — a Retry-After of 300 must
121
+ # not block the caller for five minutes; past the cap the typed error is
122
+ # raised and the caller decides. A Retry-After that parses to 0 (an
123
+ # HTTP-date, or garbage) falls through to the exponential schedule.
92
124
  def backoff_for(error, attempt)
93
- return error.retry_after if error.is_a?(RateLimitError) && error.retry_after
125
+ retry_after = error.retry_after if error.is_a?(RateLimitError)
126
+ return [retry_after, @config.max_backoff].min if retry_after&.positive?
94
127
 
95
128
  base = @config.retry_backoff * (2**(attempt - 1))
96
129
  [base + (rand * @config.retry_backoff), @config.max_backoff].min