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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 946ede1e59f03277730fa442f9df079e44bac9532db1587306f488d1d8c0a616
4
- data.tar.gz: 53ca111474c48a0e954a690caa7001b34cad1c28cd9ba2e275d2489e5f3a235c
3
+ metadata.gz: 5debc49eda9f51f36042922d2fa820f891d2c847e20f83409d7e61fa1ff2e95a
4
+ data.tar.gz: d8f9889dc16fb317efd3900b5dafc881807e60a4822b4dcab8a7ded46a2f4d39
5
5
  SHA512:
6
- metadata.gz: 96cafc3351d195aae306c22a512d0ae1ea7613b2a0d7ffdc843cfc2c37614006effae50197c38c175020ecee6145140e2271f5c26355b39a53eac7a2636626c4
7
- data.tar.gz: 381a50ca34d2084e923b4336e4d39b7d6403ad6f2c15feb03602493d0ab1ad3ebf88123bf5bf86cb75bab1a718f7709f00e4b7d8d79b1ac34973e7d22fe1ef3e
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 still
88
- switches to the new set in memory (the old refresh token is already dead) and raises
89
- `Wefunder::TokenPersistenceError`, whose `tokens` is the set that is live but not durable. Alert on it
90
- and retry the save; do not swallow it, or a restart will not be able to reconnect.
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; enum-typed ones take
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 # hermetic; spec/e2e is excluded by rake and CI on PRs
233
- bundle exec rspec spec/e2e # live sandbox, with WEFUNDER_CLIENT_ID / WEFUNDER_CLIENT_SECRET
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
 
@@ -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 set that
12
- # is now live in memory but NOT durable; +cause+ is the store's exception.
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
- attr_reader :current
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
- persist(nxt)
75
- @current = nxt
76
- @on_token_refresh&.call(nxt)
77
- nxt
97
+ @pending_tokens = nxt
98
+ publish_pending!
78
99
  end
79
100
  end
80
101
 
81
- # Persist BEFORE publishing: no other thread may use the rotated token until it is durable,
82
- # and a failed save must not leave the process working in memory but unable to reconnect
83
- # after a restart. If the store raises we still have the only copy of the rotated refresh
84
- # token (the old one is dead), so it is published anyway and the failure is surfaced as
85
- # TokenPersistenceError — handle it (alert, retry the save with +error.tokens+), don't swallow it.
86
- def persist(tokens)
87
- @store&.save(tokens)
88
- rescue StandardError => e
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
- raise TokenPersistenceError.new(tokens, e)
120
+ tokens
91
121
  end
92
122
 
93
- private
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?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wefunder
4
- VERSION = "0.1.0.beta2"
4
+ VERSION = "0.1.0.beta4"
5
5
  end
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.beta2
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-02 00:00:00.000000000 Z
11
+ date: 2026-10-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday