manza 0.3.0 → 1.2.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: 80076371c65341ec2d0505fee01860048e6198a878249315cda749dd66a848e1
4
- data.tar.gz: 489435e29915da758541243d8a02af6f94c8ee6cf5c0322341495e89b84eb9b7
3
+ metadata.gz: 23d95fcba1fba943b2dd02913102b71a982ad3548745a5a249245ab2ff120211
4
+ data.tar.gz: 38790ad40aa6bb4c3183f5114eee891e473bff53cbfcb1b2810b0ce986ba9b7e
5
5
  SHA512:
6
- metadata.gz: af686049cde39a906d1991c3bdf76b1165963c7a759573dc1f60b60ec1f309c822bb44821eedb4691cdca0fe6db0414662387aec7342f07ab45ddf1c2695bb40
7
- data.tar.gz: 720050593b2e7399068b20345d99355cd4ee11547f5e335199e28e6c4b5f03039f9472cbe73b1160b8f5e51ba7ceaf7d584dc89fa41d63e75656a13b4bbac5f7
6
+ metadata.gz: ee8d889d2ec4437866067a0806ae30d381f4efdb3a6548dd79fa9223dee1c831b890dfef7f5ee0c3068147af6acf4ad23ffaec78967b5f7776c05ceb7e51f299
7
+ data.tar.gz: 28f2074be599fe6edced2ca521b636797cd066f49d40a0a9e5dc22b8705df6884757763fd51420ee8be13a36d33b54b83f67598ed0f4d6de940843328da873df
data/CHANGELOG.md CHANGED
@@ -7,6 +7,58 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.0]
11
+
12
+ 1.1.0 was never published: its changes (the webhook verifier and the
13
+ example) ship here.
14
+
15
+ ### Added
16
+
17
+ - `manza.transfer_authorizer.get` (`GET /api/transfer_authorizer`): the
18
+ enrolled authorizer's `status`, `suspension_reason`, limits,
19
+ `daily_authorized_total`, `secret_expires_at` and `webhook_endpoint_id`.
20
+ Raises `Manza::NotFoundError` when none is enrolled.
21
+ - Documented the new pass-through fields: a draft's `authorization` carries
22
+ `refusal_reason` (status `refused` when it went to the approvers at
23
+ create time), bank accounts carry `trusted`, and the challenge webhook's
24
+ `payment.payee_trusted` says whether the payee was trusted already.
25
+ - `examples/transfer_authorizer/`: `preflight.rb` checks every setup step
26
+ and prints the fix; `create_transfer.rb` prints the authorization status
27
+ and refusal reason in words; the authorizer logs a new payee, declines it
28
+ with `ALLOW_NEW_PAYEES=false`, and retries 409 `authorization_busy`.
29
+ - README: setup step 6 is "Approve transfers to new payees" (or trust each
30
+ payee), step 7 the preflight.
31
+ - Cassettes re-recorded against `ma.manza.dev`, with new ones for
32
+ `transfer_authorizer/get`, a refused draft, a new-payee authorize and the
33
+ payee trusted afterwards.
34
+ - `Manza::Webhook` verifies incoming webhook deliveries: `verify?` (boolean),
35
+ `construct_event` (returns the parsed event, raises `Manza::ArgumentError`)
36
+ and `sign`. Reads `X-Manza-*` headers in any case, the Rack `HTTP_X_MANZA_*`
37
+ form and the legacy `X-Zazu-*` names; checks the timestamp within 5 minutes
38
+ (`tolerance: nil` skips it, for local replay only). The spec carries a fixed test vector every SDK
39
+ reuses.
40
+ - `examples/transfer_authorizer/`: a runnable two-key transfer authorizer
41
+ (Rack app plus a creator script and a JSON ledger). Not shipped in the gem.
42
+ - README: "Setup, step by step" for machine-authorized transfers.
43
+
44
+ ### Changed (cassettes)
45
+
46
+ Two existing cassettes match a different request body. An SDK that replays
47
+ `cassettes-v1.2.0` sends these exact bodies:
48
+
49
+ - `transfer_drafts/create`: `amount` `"10.00"`, the staging per-transfer
50
+ limit (the old `"150.00"` exceeds it and was refused), and a new `internal_notes`
51
+ `"[zazu-ruby-fixture] create spec - do not approve"`. The response's
52
+ `authorization` is a `pending` challenge.
53
+ - `beneficiaries/create`: `company_name` `"Manza Fixture Beneficiary spec"`
54
+ (staging now accepts only unaccented letters, digits and spaces).
55
+
56
+ Responses are scrubbed further: account `balance` and transaction
57
+ `account_balance_after` read the synthetic decimal `"1000.0"`, and the
58
+ authorizer's `daily_authorized_total` reads `"0.0"`, in every cassette.
59
+
60
+ ## [1.0.0]
61
+
10
62
  The SDK is renamed from Zazu to Manza and released as `manza` 1.0.0.
11
63
  The API surface is otherwise unchanged.
12
64
 
@@ -41,6 +93,16 @@ one-time deprecation warning per variable.
41
93
  - Fixture and staging env vars for recording are `MANZA_FIXTURE_*` and
42
94
  `MANZA_STAGING_*`, with no fallback. Rename them in your `.env`.
43
95
 
96
+ ## [0.3.1]
97
+
98
+ Final release under the old `zazu-ruby` name, published from the
99
+ `zazu-legacy` branch.
100
+
101
+ ### Deprecated
102
+
103
+ - `require "zazu"` prints a deprecation warning, and the gem shows a
104
+ post-install message pointing to `gem "manza"`.
105
+
44
106
  ## [0.3.0]
45
107
 
46
108
  ### Added
data/README.md CHANGED
@@ -82,7 +82,7 @@ manza.checkout_sessions.get("cs_...")
82
82
  manza.beneficiaries.list
83
83
  manza.beneficiaries.create(beneficiary_type: "business", company_name: "Acme Supplies", email: "ap@acme.com")
84
84
  manza.beneficiaries.list_external_accounts("01a0...")
85
- manza.beneficiaries.get_external_account("01a0...", "01a1...")
85
+ manza.beneficiaries.get_external_account("01a0...", "01a1...") # "trusted": true once a trusted payee
86
86
  manza.beneficiaries.create_external_account("01a0...", account_number: "007780...", name: "Main account")
87
87
 
88
88
  manza.payee_trust_requests.create(external_account_ids: ["01a1..."])
@@ -94,9 +94,11 @@ manza.transfer_drafts.create(
94
94
  amount: "2500.00",
95
95
  client_reference: "po_1042" # unique per entity; a duplicate raises Manza::ConflictError
96
96
  )
97
- manza.transfer_drafts.get("01a3...")
97
+ manza.transfer_drafts.get("01a3...") # authorization: status, refusal_reason
98
98
  manza.transfer_drafts.decline("01a3...", authorization_id: "01a4...", reason: "Not ours")
99
99
 
100
+ manza.transfer_authorizer.get # status, suspension_reason, limits, daily_authorized_total; NotFoundError: none enrolled
101
+
100
102
  manza.webhook_endpoints.list
101
103
  manza.webhook_endpoints.create(
102
104
  url: "https://example.com/webhooks/manza",
@@ -110,7 +112,47 @@ manza.webhook_endpoints.disable("01a0...")
110
112
 
111
113
  ## Machine-authorized transfers
112
114
 
113
- A draft inside your entity's authorization envelope (trusted payee, within limits) is sent to your enrolled authorizer endpoint as a `payment.authorization_requested` webhook carrying an `authorization.id` and a one-time `nonce`. Sign the draft from **your own record** of it with the endpoint's signing secret, and authorize it with a **different API key** from the one that created it (the creating key gets 403 `same_key_forbidden`):
115
+ A draft inside your entity's authorization envelope (trusted payee, or a new one when you allow it, within limits) is sent to your enrolled authorizer endpoint as a `payment.authorization_requested` webhook carrying an `authorization.id` and a one-time `nonce`. Your system checks it against its own ledger, signs it and authorizes it with a **different API key** from the one that created it. [`examples/transfer_authorizer/`](examples/transfer_authorizer/) runs the whole flow.
116
+
117
+ ### Setup, step by step
118
+
119
+ 1. **Feature flags.** Ask support to enable API transfers and transfer authorization (`release_api_webhook_authorization`) on your workspace.
120
+ 2. **Two API keys.** Key A creates drafts (`transfers:write`; `beneficiaries:request_trust` for step 6; `entity:read`, `accounts:read`, `webhook_endpoints:read` and `beneficiaries:read` for the preflight in step 7). Key B answers the challenges (`transfers:authorize`). The creating key is always refused with 403 `same_key_forbidden`, so keep key B and the signing secret on the system that holds your ledger. Bind key B to an IP allowlist: authorizing from a key without one is refused with 403 `unrestricted_api_key`. Bind key A to one too if you allow new payees (step 6): a draft to a new payee from a key without an allowlist goes to your approvers (`unrestricted_api_key`).
121
+ 3. **A stable public URL.** Your authorizer needs an HTTPS URL that never changes: an enrolled endpoint's URL is locked. Use a reserved ngrok domain (`ngrok http --url=your-name.ngrok.app 4599`) or a named Cloudflare tunnel (see [The authorizer tunnel](#the-authorizer-tunnel)). Never a free ngrok URL: it changes on every restart.
122
+ 4. **Create the webhook endpoint** at that URL:
123
+
124
+ ```ruby
125
+ manza.webhook_endpoints.create(url: "https://your-name.ngrok.app/", events: ["transfer.executed"])
126
+ ```
127
+
128
+ 5. **Enrol it in the app**: Settings → Developer → Transfer approval. Pick the endpoint, the currency and both limits, confirm with your two-factor code, and copy the signing secret: it is shown once.
129
+ 6. **Allow new payees, or trust each payee.** Turn on *Approve transfers to new payees* at enrolment (or later, same page, with your two-factor code) and set the *Limit per transfer to a new payee*. Your authorizer may then approve a transfer to bank details your workspace never trusted, up to that limit, and its signature trusts them: the webhook's `payment.payee_trusted` is `false` for such a transfer. Bank details whose trust was revoked, or that were edited after trusting, still go to your approvers. Without the option, an approver marks the beneficiary's bank account as trusted from the Beneficiaries list, or you ask them to:
130
+
131
+ ```ruby
132
+ manza.payee_trust_requests.create(external_account_ids: ["01a1..."])
133
+ ```
134
+
135
+ `trusted` on `manza.beneficiaries.get_external_account` says which it is. The API does not show whether the option is on.
136
+ 7. **Run the preflight, then the example** (from `examples/transfer_authorizer/`, after `bundle install` and filling in `.env`): `bundle exec ruby preflight.rb` checks what the API shows of steps 1–6 and prints a fix for each that fails. What it cannot see, check by hand: that key B is bound to an IP allowlist, and, for a payee not trusted yet, that *Approve transfers to new payees* is on and key A is bound to an IP allowlist too (it warns, it does not fail). Then `bundle exec rackup -p 4599` in one terminal, `bundle exec ruby create_transfer.rb` in another.
137
+ 8. **Troubleshooting**: a draft that goes to your approvers instead has an `authorization` with status `refused` and a `refusal_reason` (`create_transfer.rb` prints it in words); `manza.transfer_authorizer.get` shows a suspension and its `suspension_reason`. An authorize answered 409 `authorization_busy` (a bank-detail edit, trust or trust revocation on the same payee was in flight) is safe to retry. The reasons, and an API error's `type`, are in the [transfer authorization docs](https://ma.manza.finance/docs/api/transfer-authorization).
138
+
139
+ ### Verifying the webhook
140
+
141
+ Pass the **raw request body**, never re-serialized JSON. `construct_event` reads `X-Manza-Signature` and `X-Manza-Timestamp` (any case, the Rack `HTTP_X_MANZA_*` form, or the legacy `X-Zazu-*` names), checks the signature in constant time and the timestamp within 5 minutes, and returns the parsed event (a Hash). It raises `Manza::ArgumentError` otherwise:
142
+
143
+ ```ruby
144
+ begin
145
+ event = Manza::Webhook.construct_event(payload: request.body.read, headers: request.env, secret: signing_secret)
146
+ rescue Manza::ArgumentError => e
147
+ return [401, {}, [e.message]]
148
+ end
149
+ ```
150
+
151
+ `Manza::Webhook.verify?(payload:, signature:, timestamp:, secret:)` returns a boolean instead. Both accept `tolerance: nil` to skip the timestamp check. Use it only to replay a captured delivery in a local tool or test, never on a live endpoint: without the check, anyone holding a captured delivery can replay it.
152
+
153
+ ### Signing the challenge
154
+
155
+ Sign the draft from **your own record** of it with the endpoint's signing secret:
114
156
 
115
157
  ```ruby
116
158
  input = Manza::TransferAuthorization.signature_input(
@@ -198,13 +240,14 @@ The spec suite is VCR-backed — cassettes live in `spec/fixtures/cassettes/` an
198
240
  To re-record cassettes against staging:
199
241
 
200
242
  ```bash
201
- cp .env.example .env
243
+ bin/rename-env-vars # upgrading an existing 0.x .env: ZAZU_* → MANZA_* (backup in .env.bak)
244
+ cp .env.example .env # fresh setup only — overwrites an existing .env
202
245
  # fill in the keys, MANZA_FIXTURE_ACCOUNT_ID and MANZA_FIXTURE_BENEFICIARY_ID
203
246
  cloudflared tunnel --config ~/.cloudflared/zazu-sdk-authorizer.yml run zazu-sdk-authorizer # separate terminal
204
247
  bundle exec rake fixtures:record
205
248
  ```
206
249
 
207
- Recording executes a real 10.00 MAD transfer on staging (the authorize cassette). The one-time staging setup (keys, authorizer enrolment, trusted payee) is listed at the top of `lib/tasks/fixtures.rake`.
250
+ Recording executes two real 10.00 MAD transfers on staging: the authorize cassette pays the trusted payee, and authorize_new_payee pays a freshly seeded bank account (and trusts it). The one-time staging setup (keys, authorizer enrolment, trusted payee, the new-payee option) is listed at the top of `lib/tasks/fixtures.rake`; AGENTS.md → Staging fixture state keeps it in place.
208
251
 
209
252
  ### The authorizer tunnel
210
253
 
@@ -240,12 +283,26 @@ Check it end to end: with the tunnel running and nothing on port 4599, `curl -X
240
283
 
241
284
  Cassettes are scrubbed before write — bearer tokens and request IDs are rewritten to placeholders. Even if a real key is in `.env`, the committed cassette never contains it.
242
285
 
286
+ ## The SDK family
287
+
288
+ | SDK | Repository | Install |
289
+ |---|---|---|
290
+ | Ruby (reference implementation, records the cassettes) | [getmanza/manza-ruby](https://github.com/getmanza/manza-ruby) (this repo) | `gem "manza"` |
291
+ | TypeScript / JavaScript | [getmanza/manza-ts](https://github.com/getmanza/manza-ts) | `npm install @getmanza/sdk` |
292
+ | Python | [getmanza/manza-python](https://github.com/getmanza/manza-python) | `pip install manza` |
293
+ | Go | [getmanza/manza-go](https://github.com/getmanza/manza-go) | `go get github.com/getmanza/manza-go` |
294
+ | PHP | [getmanza/manza-php](https://github.com/getmanza/manza-php) | `composer require manza/manza-php` |
295
+ | Rust | [getmanza/manza-rust](https://github.com/getmanza/manza-rust) | `cargo add manza` |
296
+ | Crystal | [getmanza/manza-crystal](https://github.com/getmanza/manza-crystal) | shard `manza` (`github: getmanza/manza-crystal`) |
297
+ | Elixir | [getmanza/manza-elixir](https://github.com/getmanza/manza-elixir) | `{:manza, "~> 1.0"}` |
298
+ | CLI | [getmanza/cli](https://github.com/getmanza/cli) | `npm install -g @getzazu/cli` or `brew install getmanza/tap/zazu` |
299
+
243
300
  ## Cassettes for other-language SDKs
244
301
 
245
302
  Each release of `manza-ruby` publishes the cassette directory as a tarball release asset:
246
303
 
247
304
  ```
248
- https://github.com/getmanza/manza-ruby/releases/download/v0.1.0/cassettes-v0.1.0.tar.gz
305
+ https://github.com/getmanza/manza-ruby/releases/download/v1.0.0/cassettes-v1.0.0.tar.gz
249
306
  ```
250
307
 
251
308
  `manza-go`, `manza-python`, etc. pin a specific tag in their cassette fetch script and download the tarball during CI. This guarantees every SDK is tested against the same recorded API interactions, surfacing cross-SDK inconsistencies immediately.
data/lib/manza/client.rb CHANGED
@@ -91,6 +91,10 @@ module Manza
91
91
  @payee_trust_requests ||= Resources::PayeeTrustRequests.new(self)
92
92
  end
93
93
 
94
+ def transfer_authorizer
95
+ @transfer_authorizer ||= Resources::TransferAuthorizer.new(self)
96
+ end
97
+
94
98
  def transfer_drafts
95
99
  @transfer_drafts ||= Resources::TransferDrafts.new(self)
96
100
  end
@@ -4,7 +4,10 @@ module Manza
4
4
  module Resources
5
5
  # Saved transfer recipients. Each beneficiary embeds its bank
6
6
  # accounts; the one flagged `default` is used when a transfer names
7
- # only the beneficiary_id. There is no update or delete via the API.
7
+ # only the beneficiary_id. Each bank account carries `trusted`: whether
8
+ # its bank details are a trusted payee, which machine-authorized
9
+ # transfers need (or "Approve transfers to new payees" on for details
10
+ # never trusted). There is no update or delete via the API.
8
11
  class Beneficiaries < Base
9
12
  # GET /api/beneficiaries
10
13
  def list(limit: MAX_PER_PAGE, cursor: nil)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manza
4
+ module Resources
5
+ # The entity's enrolled transfer authorizer, read-only. Enrolment,
6
+ # limits, the "Approve transfers to new payees" option and resume stay
7
+ # in the app behind a second factor. A singular resource: no list, no id.
8
+ #
9
+ # manza.transfer_authorizer.get.body["status"] # => "active"
10
+ class TransferAuthorizer < Base
11
+ # GET /api/transfer_authorizer
12
+ #
13
+ # Any key with `transfers:read`, `transfers:write` or
14
+ # `transfers:authorize`. Returns `status` (`active` / `suspended`),
15
+ # `suspension_reason` (`member`, `signature_failures`,
16
+ # `secret_expired`, `endpoint_disabled`; nil when active),
17
+ # `currency_code`, `per_transfer_limit`, `daily_limit`,
18
+ # `daily_authorized_total` (the last 24 hours), `secret_expires_at`,
19
+ # `webhook_endpoint_id` and `enrolled_at`. It does not show whether
20
+ # new payees are approved. Raises {Manza::NotFoundError} when no
21
+ # authorizer is enrolled.
22
+ def get
23
+ http_get("api/transfer_authorizer")
24
+ end
25
+ end
26
+ end
27
+ end
@@ -12,6 +12,31 @@ module Manza
12
12
  # representative approves it. Poll {#get} (status: requested →
13
13
  # processing → completed / failed) or subscribe to the
14
14
  # `transfer.executed` webhook to follow execution.
15
+ #
16
+ # A draft's `authorization` says which way it went: `pending` (a
17
+ # challenge was sent), `authorized`, `declined` (the draft is deleted),
18
+ # or one of two outcomes that send the draft to the in-app approvers,
19
+ # each with a `refusal_reason`:
20
+ #
21
+ # - `refused`: it did not qualify, at create time (no webhook is sent)
22
+ # or again when the challenge was answered: `untrusted_payee`,
23
+ # `unrestricted_api_key`, `over_new_payee_limit`,
24
+ # `over_transfer_limit`, `over_daily_limit`, `currency_mismatch`,
25
+ # `suspended` or `secret_expired`.
26
+ # - `expired` / `superseded` / `failed`: a challenge was sent and closed
27
+ # without an authorize: `expired` (no answer within the hour),
28
+ # `stale` (the draft, its payee's bank details or the endpoint changed
29
+ # while it was open) or `signature_failed` (five invalid signatures).
30
+ # One exception: the in-app approval that finalizes the draft while
31
+ # its challenge is open supersedes it (`stale`), and the draft
32
+ # executes or is scheduled; the draft's own `status` shows which.
33
+ #
34
+ # `authorization` is nil when no authorizer was enrolled.
35
+ #
36
+ # With "Approve transfers to new payees" on (enrolment, in the app),
37
+ # a draft to bank details the entity never trusted is challenged too,
38
+ # up to the new-payee limit, when the creating key has an IP
39
+ # allowlist; authorizing it also trusts the payee.
15
40
  class TransferDrafts < Base
16
41
  # POST /api/transfer_drafts
17
42
  #
@@ -36,8 +61,12 @@ module Manza
36
61
  # `payment.authorization_requested` webhook; build `signature` with
37
62
  # {Manza::TransferAuthorization}. Requires the `transfers:authorize`
38
63
  # scope on a key other than the draft's creator (otherwise 403
39
- # `same_key_forbidden`). A blank signature is refused locally: the
40
- # API counts it as a failed attempt, and five fail the challenge.
64
+ # `same_key_forbidden`), bound to an IP allowlist (otherwise 403
65
+ # `unrestricted_api_key`). A 409 {Manza::ConflictError} with `type`
66
+ # `authorization_busy` means a bank-detail edit, trust or trust
67
+ # revocation on the payee was in flight: retry. A blank signature
68
+ # raises {Manza::ArgumentError} and is never sent: the API would count
69
+ # it as a failed attempt. Five invalid signatures fail the challenge.
41
70
  def authorize(id, authorization_id:, signature:)
42
71
  raise Manza::ArgumentError, "signature cannot be blank" if signature.to_s.strip.empty?
43
72
 
@@ -50,7 +79,8 @@ module Manza
50
79
  # POST /api/transfer_drafts/:id/decline
51
80
  #
52
81
  # Declines the challenge and deletes the draft. Returns the
53
- # authorization (`status: "declined"`).
82
+ # authorization (`status: "declined"`, `declined_at`, and a nil
83
+ # `refusal_reason`).
54
84
  def decline(id, authorization_id:, reason: nil)
55
85
  http_post(
56
86
  encode_path("api/transfer_drafts", id, "decline"),
data/lib/manza/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Manza
4
- VERSION = "0.3.0"
4
+ VERSION = "1.2.0"
5
5
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module Manza
7
+ # Verifies incoming webhook deliveries. Pure functions — no HTTP.
8
+ #
9
+ # Every delivery carries `X-Manza-Signature` (hex HMAC-SHA256 of
10
+ # "<timestamp>.<raw body>" under the endpoint's signing secret) and
11
+ # `X-Manza-Timestamp` (Unix seconds, fresh on every attempt). The
12
+ # legacy `X-Zazu-*` names carry the same values.
13
+ #
14
+ # Pass the **raw request body**, never re-serialized JSON:
15
+ #
16
+ # event = Manza::Webhook.construct_event(
17
+ # payload: request.body.read, headers: request.env, secret: signing_secret
18
+ # )
19
+ # event["event"] # => "payment.authorization_requested"
20
+ module Webhook
21
+ DEFAULT_TOLERANCE = 300 # seconds
22
+
23
+ module_function
24
+
25
+ # Hex HMAC-SHA256 of "<timestamp>.<payload>". For tests and local tools.
26
+ def sign(payload:, timestamp:, secret:)
27
+ OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")
28
+ end
29
+
30
+ # true when the signature matches (constant time) and the timestamp is
31
+ # within `tolerance` seconds of `now`. `tolerance: nil` skips the
32
+ # timestamp check (for replaying a captured delivery). Never raises on
33
+ # bad input; returns false.
34
+ def verify?(payload:, signature:, timestamp:, secret:, tolerance: DEFAULT_TOLERANCE, now: Time.now)
35
+ signature_matches?(payload, signature, timestamp, secret) && timestamp_fresh?(timestamp, tolerance, now)
36
+ end
37
+
38
+ # Reads X-Manza-Signature / X-Manza-Timestamp from any Hash-like
39
+ # headers (case-insensitive, X-Zazu-* fallback, Rack HTTP_X_MANZA_*
40
+ # form too), verifies them, and returns the parsed JSON Hash. Raises
41
+ # Manza::ArgumentError when a header is missing, the signature does
42
+ # not match, the timestamp is outside tolerance, or the body is not
43
+ # a JSON object.
44
+ def construct_event(payload:, headers:, secret:, tolerance: DEFAULT_TOLERANCE, now: Time.now)
45
+ normalized = normalize_headers(headers)
46
+ signature = header(normalized, "signature")
47
+ timestamp = header(normalized, "timestamp")
48
+
49
+ raise Manza::ArgumentError, "invalid webhook signature" unless signature_matches?(payload, signature, timestamp, secret)
50
+ raise Manza::ArgumentError, "webhook timestamp outside tolerance" unless timestamp_fresh?(timestamp, tolerance, now)
51
+
52
+ event =
53
+ begin
54
+ JSON.parse(payload)
55
+ rescue JSON::ParserError
56
+ raise Manza::ArgumentError, "webhook payload is not JSON"
57
+ end
58
+ raise Manza::ArgumentError, "webhook payload is not a JSON object" unless event.is_a?(Hash)
59
+
60
+ event
61
+ end
62
+
63
+ def signature_matches?(payload, signature, timestamp, secret)
64
+ return false unless payload.is_a?(String) && secret.is_a?(String) && !secret.empty?
65
+ return false unless [signature, timestamp].all? { |v| v.is_a?(String) || v.is_a?(Integer) }
66
+ return false unless parse_timestamp(timestamp)
67
+
68
+ expected = sign(payload: payload, timestamp: timestamp, secret: secret)
69
+ return false unless expected.bytesize == signature.to_s.bytesize
70
+
71
+ OpenSSL.fixed_length_secure_compare(expected, signature.to_s)
72
+ end
73
+
74
+ def timestamp_fresh?(timestamp, tolerance, now)
75
+ return true if tolerance.nil?
76
+
77
+ seconds = parse_timestamp(timestamp)
78
+ !seconds.nil? && (now.to_i - seconds).abs <= tolerance
79
+ end
80
+
81
+ def parse_timestamp(timestamp)
82
+ Integer(timestamp.to_s, 10)
83
+ rescue ::ArgumentError, TypeError
84
+ nil
85
+ end
86
+
87
+ def normalize_headers(headers)
88
+ headers.to_h.transform_keys { |name| name.to_s.sub(/\AHTTP_/i, "").tr("_", "-").downcase }
89
+ end
90
+
91
+ def header(normalized, suffix)
92
+ value = normalized["x-manza-#{suffix}"] || normalized["x-zazu-#{suffix}"]
93
+ return value.to_s unless value.nil? || value.to_s.empty?
94
+
95
+ raise Manza::ArgumentError, "missing X-Manza-#{suffix.capitalize} header"
96
+ end
97
+
98
+ private_class_method :signature_matches?, :timestamp_fresh?, :parse_timestamp, :normalize_headers, :header
99
+ end
100
+ end
data/lib/manza.rb CHANGED
@@ -21,6 +21,7 @@ require_relative "manza/errors"
21
21
  require_relative "manza/response"
22
22
  require_relative "manza/page"
23
23
  require_relative "manza/transfer_authorization"
24
+ require_relative "manza/webhook"
24
25
  require_relative "manza/resources/base"
25
26
  require_relative "manza/resources/accounts"
26
27
  require_relative "manza/resources/beneficiaries"
@@ -30,6 +31,7 @@ require_relative "manza/resources/entity"
30
31
  require_relative "manza/resources/invoices"
31
32
  require_relative "manza/resources/payee_trust_requests"
32
33
  require_relative "manza/resources/payment_links"
34
+ require_relative "manza/resources/transfer_authorizer"
33
35
  require_relative "manza/resources/transfer_drafts"
34
36
  require_relative "manza/resources/webhook_endpoints"
35
37
  require_relative "manza/client"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: manza
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Manza
@@ -76,11 +76,13 @@ files:
76
76
  - lib/manza/resources/invoices.rb
77
77
  - lib/manza/resources/payee_trust_requests.rb
78
78
  - lib/manza/resources/payment_links.rb
79
+ - lib/manza/resources/transfer_authorizer.rb
79
80
  - lib/manza/resources/transfer_drafts.rb
80
81
  - lib/manza/resources/webhook_endpoints.rb
81
82
  - lib/manza/response.rb
82
83
  - lib/manza/transfer_authorization.rb
83
84
  - lib/manza/version.rb
85
+ - lib/manza/webhook.rb
84
86
  homepage: https://github.com/getmanza/manza-ruby
85
87
  licenses:
86
88
  - MIT
@@ -104,7 +106,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
104
106
  - !ruby/object:Gem::Version
105
107
  version: '0'
106
108
  requirements: []
107
- rubygems_version: 4.0.6
109
+ rubygems_version: 3.6.9
108
110
  specification_version: 4
109
111
  summary: Ruby SDK for the Manza API
110
112
  test_files: []