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 +4 -4
- data/CHANGELOG.md +62 -0
- data/README.md +63 -6
- data/lib/manza/client.rb +4 -0
- data/lib/manza/resources/beneficiaries.rb +4 -1
- data/lib/manza/resources/transfer_authorizer.rb +27 -0
- data/lib/manza/resources/transfer_drafts.rb +33 -3
- data/lib/manza/version.rb +1 -1
- data/lib/manza/webhook.rb +100 -0
- data/lib/manza.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 23d95fcba1fba943b2dd02913102b71a982ad3548745a5a249245ab2ff120211
|
|
4
|
+
data.tar.gz: 38790ad40aa6bb4c3183f5114eee891e473bff53cbfcb1b2810b0ce986ba9b7e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`.
|
|
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
|
-
|
|
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
|
|
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/
|
|
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.
|
|
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`)
|
|
40
|
-
#
|
|
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
|
@@ -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:
|
|
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:
|
|
109
|
+
rubygems_version: 3.6.9
|
|
108
110
|
specification_version: 4
|
|
109
111
|
summary: Ruby SDK for the Manza API
|
|
110
112
|
test_files: []
|