wefunder 0.1.0.beta2 → 0.1.0.beta4
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/README.md +16 -8
- data/lib/wefunder/client.rb +57 -1
- data/lib/wefunder/token_manager.rb +50 -18
- data/lib/wefunder/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5debc49eda9f51f36042922d2fa820f891d2c847e20f83409d7e61fa1ff2e95a
|
|
4
|
+
data.tar.gz: d8f9889dc16fb317efd3900b5dafc881807e60a4822b4dcab8a7ded46a2f4d39
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9b7c81d6afb161eacc9a727aaa4bcca3720a6b6bc77e3c8efc332ac594e5e35581680d4ad847c6dd6fb3df5d7382eb2149fcff0f072e0b75d189b96281cee6bc
|
|
7
|
+
data.tar.gz: 85613d6d2be67d40039f2a9a953682ee180d45811076f4e477629f9a2effce804274e1726c4bbf098b1d8d59ef7fc4fbf5a9fab358a1d656c23088265ea1ff87
|
data/README.md
CHANGED
|
@@ -84,10 +84,11 @@ class TokenStore
|
|
|
84
84
|
end
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
-
The rotated set is saved **before** any thread can use it. If `store.save` raises, the SDK
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
and
|
|
87
|
+
The rotated set is saved **before** any thread can use it. If `store.save` or `on_token_refresh` raises, the SDK raises
|
|
88
|
+
`Wefunder::TokenPersistenceError` and keeps the rotated set *pending*: no request is made with it, the
|
|
89
|
+
consumed refresh token is never reused, and the next call retries the save (or persist
|
|
90
|
+
`error.tokens` yourself and call `wf.token_manager.mark_persisted!(error.tokens)`; a stale acknowledgment after another rotation is a no-op). Alert on this error; a process
|
|
91
|
+
that keeps failing to save cannot reconnect after a restart.
|
|
91
92
|
|
|
92
93
|
Concurrent requests that hit a 401 at the same time share one refresh (the client is thread-safe).
|
|
93
94
|
If several application instances can use the same OAuth connection, serialize refreshes for that
|
|
@@ -102,8 +103,12 @@ portfolio = wf.portfolio.get
|
|
|
102
103
|
```
|
|
103
104
|
|
|
104
105
|
Namespaces: `users`, `offerings`, `investments`, `portfolio`, `campaigns`, `syndicates`, `intents`,
|
|
105
|
-
`attribution`, and `webhook_endpoints`. Query parameters are keyword arguments;
|
|
106
|
-
plain strings, and `Time` / `DateTime` / `Date` values are sent as ISO 8601.
|
|
106
|
+
`attribution`, `installations`, and `webhook_endpoints`. Query parameters are keyword arguments;
|
|
107
|
+
enum-typed ones take plain strings, and `Time` / `DateTime` / `Date` values are sent as ISO 8601.
|
|
108
|
+
|
|
109
|
+
`wf.installations` lets your app act as a company or syndicate: `eligible_targets`, `create`,
|
|
110
|
+
`mint_token`, `list`, `get`, `revoke`, and `install_or_mint_token`, which handles the API's 409
|
|
111
|
+
`already_installed` answer by minting a token for the existing install with the same scopes.
|
|
107
112
|
|
|
108
113
|
`wf.investments` is the Investment Delta API. `list` without a cursor bootstraps; pass `updated_since:`
|
|
109
114
|
or the `meta.next_cursor` you saved from your last page to receive only records that changed since then.
|
|
@@ -229,10 +234,13 @@ sends a fully-wrapped request and returns the decoded JSON.
|
|
|
229
234
|
```bash
|
|
230
235
|
bundle install
|
|
231
236
|
bundle exec rubocop
|
|
232
|
-
bundle exec rspec
|
|
233
|
-
bundle exec rspec spec/e2e
|
|
237
|
+
bundle exec rspec # hermetic — live examples are tagged :e2e and filtered out
|
|
238
|
+
WEFUNDER_E2E=1 bundle exec rspec spec/e2e # live sandbox; also needs WEFUNDER_CLIENT_ID / WEFUNDER_CLIENT_SECRET
|
|
234
239
|
```
|
|
235
240
|
|
|
241
|
+
The live group is opt-in by tag, not by path: without `WEFUNDER_E2E=1` it never runs, even when sandbox
|
|
242
|
+
credentials happen to be exported in your shell.
|
|
243
|
+
|
|
236
244
|
Generated files in `lib/wefunder_generated/` come from `openapi/openapi.yaml` via `script/generate`
|
|
237
245
|
(openapi-generator in Docker; no Java needed) and are never edited by hand.
|
|
238
246
|
|
data/lib/wefunder/client.rb
CHANGED
|
@@ -26,7 +26,7 @@ module Wefunder
|
|
|
26
26
|
# typed-error unwrapping, and lazy auto-pagination. +raw+ exposes every generated API.
|
|
27
27
|
class Client
|
|
28
28
|
attr_reader :mode, :users, :offerings, :investments, :portfolio, :campaigns, :syndicates, :intents,
|
|
29
|
-
:attribution, :webhook_endpoints
|
|
29
|
+
:attribution, :installations, :webhook_endpoints
|
|
30
30
|
|
|
31
31
|
def initialize(access_token: nil, tokens: nil, client_id: nil, client_secret: nil, client_credentials: nil,
|
|
32
32
|
api_version: DEFAULT_API_VERSION, base_url: DEFAULT_API_BASE_URL, authorize_base_url: nil,
|
|
@@ -53,6 +53,7 @@ module Wefunder
|
|
|
53
53
|
@syndicates = Syndicates.new(self)
|
|
54
54
|
@intents = Intents.new(self)
|
|
55
55
|
@attribution = Attribution.new(self)
|
|
56
|
+
@installations = Installations.new(self)
|
|
56
57
|
@webhook_endpoints = WebhookEndpoints.new(self)
|
|
57
58
|
end
|
|
58
59
|
|
|
@@ -70,6 +71,9 @@ module Wefunder
|
|
|
70
71
|
# The current token set (rotated refresh token included).
|
|
71
72
|
def tokens = @token_manager.current
|
|
72
73
|
|
|
74
|
+
# The TokenManager (for +mark_persisted!+ after handling a TokenPersistenceError).
|
|
75
|
+
attr_reader :token_manager
|
|
76
|
+
|
|
73
77
|
# The generated APIs, one accessor per tag (+wf.raw.syndicate_members.list_syndicate_members(id)+).
|
|
74
78
|
# Wrap calls in +wrap+ to get typed errors.
|
|
75
79
|
attr_reader :raw
|
|
@@ -272,6 +276,58 @@ module Wefunder
|
|
|
272
276
|
def me = data_of(wrap { raw.attribution_partners.get_attribution_me })
|
|
273
277
|
end
|
|
274
278
|
|
|
279
|
+
# /installations (read:installations / write:installations; write does not imply read). An
|
|
280
|
+
# install lets your app act AS a company or syndicate: +create+ and +mint_token+ return the
|
|
281
|
+
# envelope whose +token.access_token+ is a company-owned token (shown once, no expiry, no
|
|
282
|
+
# refresh) — build a second client with it. Installing is also what makes a company or
|
|
283
|
+
# syndicate an audience for your webhooks.
|
|
284
|
+
class Installations < Namespace
|
|
285
|
+
# Companies / syndicates the token's user may install your app on (empty for an investor).
|
|
286
|
+
def eligible_targets(target_type: nil)
|
|
287
|
+
opts = target_type ? { target_type: target_type } : {}
|
|
288
|
+
data_of(wrap { raw.installations.list_eligible_install_targets(opts) }) || []
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Every install of your app; the envelope (+meta.count+).
|
|
292
|
+
def list = wrap { raw.installations.list_installations }
|
|
293
|
+
def get(installation_id) = data_of(wrap { raw.installations.get_installation(installation_id) })
|
|
294
|
+
|
|
295
|
+
# Install on a target (+target_type:+, +target_id:+, optional +scopes:+ / +tier:+). Returns the
|
|
296
|
+
# envelope: +data+ is the install, +token.access_token+ the one-time token. If the app is
|
|
297
|
+
# already installed there the API answers 409 +already_installed+ with
|
|
298
|
+
# +details["installation"]+ = the existing id; see +install_or_mint_token+.
|
|
299
|
+
def create(**attrs)
|
|
300
|
+
attrs = attrs.transform_keys(&:to_sym)
|
|
301
|
+
wrap { raw.installations.create_installation(WefunderGenerated::CreateInstallationRequest.new(attrs)) }
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# A fresh company-owned token for an existing install. +scopes+ narrows within the install's
|
|
305
|
+
# ceiling; omit for the ceiling. An explicit +[]+ grants nothing, so only nil omits the body.
|
|
306
|
+
def mint_token(installation_id, scopes = nil)
|
|
307
|
+
opts = {}
|
|
308
|
+
opts[:create_installation_token_request] = WefunderGenerated::CreateInstallationTokenRequest.new(scopes: scopes) if scopes
|
|
309
|
+
wrap { raw.installations.create_installation_token(installation_id, opts) }
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# +create+, falling back to +mint_token+ for the existing install on 409 +already_installed+.
|
|
313
|
+
# The mint re-requests +scopes+ so a retry never widens the grant. Any other error (revoked
|
|
314
|
+
# install, missing scope) still raises.
|
|
315
|
+
def install_or_mint_token(**attrs)
|
|
316
|
+
# Normalize once so the fallback re-requests exactly what create sent — string-keyed
|
|
317
|
+
# attrs (e.g. from JSON.parse) must not turn a restricted or empty grant into the ceiling.
|
|
318
|
+
attrs = attrs.transform_keys(&:to_sym)
|
|
319
|
+
create(**attrs)
|
|
320
|
+
rescue Wefunder::Error => e
|
|
321
|
+
existing_id = e.details["installation"] if e.type == "already_installed" && e.details.is_a?(Hash)
|
|
322
|
+
raise unless existing_id.is_a?(String) && !existing_id.empty?
|
|
323
|
+
|
|
324
|
+
mint_token(existing_id, attrs[:scopes])
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Revoke an install: its tokens stop working at once. Returns the install, now +revoked+.
|
|
328
|
+
def revoke(installation_id) = data_of(wrap { raw.installations.revoke_installation(installation_id) })
|
|
329
|
+
end
|
|
330
|
+
|
|
275
331
|
# Endpoints belong to your application and are managed through the LIVE API
|
|
276
332
|
# (write:webhooks). The signing secret is returned only on create and rotate.
|
|
277
333
|
class WebhookEndpoints < Namespace
|
|
@@ -8,8 +8,10 @@ module Wefunder
|
|
|
8
8
|
# manager already holds a different one, another caller recovered in the meantime and the
|
|
9
9
|
# current set is returned without a round-trip. The rotated set is persisted (store +
|
|
10
10
|
# callback) BEFORE the retried request can use it. Thread-safe (Mutex).
|
|
11
|
-
# Raised when the token store failed to save a rotated token set. +tokens+ is the
|
|
12
|
-
# is
|
|
11
|
+
# Raised when the token store failed to save a rotated token set. +tokens+ is the rotated
|
|
12
|
+
# set that is NOT yet durable and NOT yet in use; the manager keeps it pending and retries
|
|
13
|
+
# the save on the next call (or persist it yourself and call +mark_persisted!(tokens)+). Until it is
|
|
14
|
+
# saved no request is made with it — the consumed refresh token is never reused either.
|
|
13
15
|
class TokenPersistenceError < StandardError
|
|
14
16
|
attr_reader :tokens
|
|
15
17
|
|
|
@@ -22,7 +24,9 @@ module Wefunder
|
|
|
22
24
|
class TokenManager
|
|
23
25
|
DEFAULT_EXPIRY_LEEWAY_SECONDS = 30.0
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
# The durable, in-use token set. A rotated set that could not be persisted is held in
|
|
28
|
+
# +pending_tokens+ instead until a save succeeds.
|
|
29
|
+
attr_reader :current, :pending_tokens
|
|
26
30
|
|
|
27
31
|
def initialize(tokens, client_id: nil, client_secret: nil, re_mint: nil, on_token_refresh: nil, store: nil,
|
|
28
32
|
faraday: nil, now: nil, expiry_leeway_seconds: DEFAULT_EXPIRY_LEEWAY_SECONDS,
|
|
@@ -46,16 +50,35 @@ module Wefunder
|
|
|
46
50
|
can_rotate? || !@re_mint.nil?
|
|
47
51
|
end
|
|
48
52
|
|
|
49
|
-
# A valid access token, refreshing proactively if expired / within the leeway.
|
|
53
|
+
# A valid access token, refreshing proactively if expired / within the leeway. If a rotated
|
|
54
|
+
# set is pending persistence, the save is retried first — no request uses an undurable token.
|
|
50
55
|
def access_token
|
|
56
|
+
@lock.synchronize { publish_pending! if @pending_tokens } if @pending_tokens # re-check under the lock
|
|
51
57
|
refresh(stale_token: @current.access_token) if near_expiry? && can_refresh?
|
|
52
58
|
@current.access_token
|
|
53
59
|
end
|
|
54
60
|
|
|
61
|
+
# Tell the manager you persisted +tokens+ (the set from a TokenPersistenceError) yourself.
|
|
62
|
+
# Publishes it only if it is still the pending set; a stale acknowledgment (the manager has
|
|
63
|
+
# since rotated again) is a no-op and returns false, so an older save can never publish a
|
|
64
|
+
# newer, unsaved set.
|
|
65
|
+
def mark_persisted!(tokens)
|
|
66
|
+
@lock.synchronize do
|
|
67
|
+
return false unless @pending_tokens && same_token_set?(@pending_tokens, tokens)
|
|
68
|
+
|
|
69
|
+
@current = @pending_tokens
|
|
70
|
+
@pending_tokens = nil
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
55
75
|
# Recover the token (after a 401 or proactively). If +stale_token+ is no longer the current
|
|
56
76
|
# token, someone else already recovered and the current set is returned.
|
|
57
77
|
def refresh(stale_token: nil)
|
|
58
78
|
@lock.synchronize do
|
|
79
|
+
# A rotated set awaiting persistence: retry the save rather than rotating again (the
|
|
80
|
+
# old refresh token was consumed by that rotation).
|
|
81
|
+
return publish_pending! if @pending_tokens
|
|
59
82
|
return @current if stale_token && @current.access_token != stale_token
|
|
60
83
|
|
|
61
84
|
previous_refresh = @current.refresh_token
|
|
@@ -71,26 +94,35 @@ module Wefunder
|
|
|
71
94
|
else
|
|
72
95
|
raise AuthError, "Access token expired and no refresh token / re-mint capability is configured."
|
|
73
96
|
end
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
@on_token_refresh&.call(nxt)
|
|
77
|
-
nxt
|
|
97
|
+
@pending_tokens = nxt
|
|
98
|
+
publish_pending!
|
|
78
99
|
end
|
|
79
100
|
end
|
|
80
101
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# Persist BEFORE publishing (caller holds the lock): no thread may use the rotated token
|
|
105
|
+
# until it is durable, and a failed save must not leave the process working in memory but
|
|
106
|
+
# unable to reconnect after a restart. On failure the set stays in +pending_tokens+ and
|
|
107
|
+
# TokenPersistenceError is raised; the next call retries the save.
|
|
108
|
+
def publish_pending!
|
|
109
|
+
tokens = @pending_tokens
|
|
110
|
+
begin
|
|
111
|
+
@store&.save(tokens)
|
|
112
|
+
# on_token_refresh is a persistence path too, so it runs BEFORE publication and a failure
|
|
113
|
+
# keeps the set pending exactly like a store failure.
|
|
114
|
+
@on_token_refresh&.call(tokens)
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
raise TokenPersistenceError.new(tokens, e)
|
|
117
|
+
end
|
|
118
|
+
@pending_tokens = nil
|
|
89
119
|
@current = tokens
|
|
90
|
-
|
|
120
|
+
tokens
|
|
91
121
|
end
|
|
92
122
|
|
|
93
|
-
|
|
123
|
+
def same_token_set?(a, b)
|
|
124
|
+
a.access_token == b.access_token && a.refresh_token == b.refresh_token
|
|
125
|
+
end
|
|
94
126
|
|
|
95
127
|
def can_rotate?
|
|
96
128
|
!@current.refresh_token.nil? && !@current.refresh_token.empty? && !@client_id.nil?
|
data/lib/wefunder/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: wefunder
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.0.
|
|
4
|
+
version: 0.1.0.beta4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Wefunder
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|