wefunder 0.1.0.beta1 → 0.1.0.beta3

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: 8074f860a3e500b08ddab7a4fa3f7ebecb0ec937926aa52884e666243c310715
4
- data.tar.gz: 22dfeff5e30c9aaa4e1def2805eab0c33795d53b69140518b364ed878b257d5c
3
+ metadata.gz: a3ac10a62a698ee6cb163f3ec6e100248d461dc6c55bf97caeb1bac349b44af6
4
+ data.tar.gz: 26d0e08433a88d288942da627f8d509ffa196e6e2ed3cc347bae43beadaf84bf
5
5
  SHA512:
6
- metadata.gz: ad2bc61f75b11d9d1164b81e4d5232f82478a28557ba018f6a7c922f4af5a451e690c05fdcbc9537a1013ea43e9f1328476252ff2f1e14ab3abcde0d48969ccc
7
- data.tar.gz: 7dfd26d68fd440c078f8a0f2c5db20fe8fd44c523e85230b86f4bb83eec571f9217185fd61b70a3560f8a6d1c86a1c2fd4de3e58368e0310a32769e108fa3672
6
+ metadata.gz: 4ee97ef8052e3e7a9476a8b0243b52a706bfd2c6a5911e1b9362c07e5d5d6290529615ed6daace3d953769d126a492440692ebef5191cd7846c418a70f6a54b5
7
+ data.tar.gz: 535800baceafa25b8fcb744d2a1fc16a0596e14d80db5fba6f0c83772ee371c16a361335f82819b5a5e664bf016d17b008d3ff46a3f07f527eab491d8aa36bdd
data/README.md CHANGED
@@ -84,6 +84,12 @@ class TokenStore
84
84
  end
85
85
  ```
86
86
 
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.
92
+
87
93
  Concurrent requests that hit a 401 at the same time share one refresh (the client is thread-safe).
88
94
  If several application instances can use the same OAuth connection, serialize refreshes for that
89
95
  connection yourself.
@@ -98,7 +104,7 @@ portfolio = wf.portfolio.get
98
104
 
99
105
  Namespaces: `users`, `offerings`, `investments`, `portfolio`, `campaigns`, `syndicates`, `intents`,
100
106
  `attribution`, and `webhook_endpoints`. Query parameters are keyword arguments; enum-typed ones take
101
- plain strings.
107
+ plain strings, and `Time` / `DateTime` / `Date` values are sent as ISO 8601.
102
108
 
103
109
  `wf.investments` is the Investment Delta API. `list` without a cursor bootstraps; pass `updated_since:`
104
110
  or the `meta.next_cursor` you saved from your last page to receive only records that changed since then.
@@ -136,7 +142,11 @@ end
136
142
 
137
143
  The SDK retries idempotent `GET` requests after transient network errors, `5xx` responses, and rate
138
144
  limits (honouring `X-RateLimit-Reset`). Write requests are not retried automatically, except once after
139
- a `401` has been recovered.
145
+ a `401` has been recovered. Exhausted network failures raise `Wefunder::Error` with `status` 0 and
146
+ `type` `"network_error"`, from namespaces and `request` alike.
147
+
148
+ `timeout:` (seconds, default 30) applies to every request the client makes: API calls, `request`, and
149
+ the OAuth token round-trips for refresh and re-mint.
140
150
 
141
151
  ## Webhooks
142
152
 
@@ -220,10 +230,13 @@ sends a fully-wrapped request and returns the decoded JSON.
220
230
  ```bash
221
231
  bundle install
222
232
  bundle exec rubocop
223
- bundle exec rspec # hermetic; spec/e2e is excluded by rake and CI on PRs
224
- bundle exec rspec spec/e2e # live sandbox, with WEFUNDER_CLIENT_ID / WEFUNDER_CLIENT_SECRET
233
+ bundle exec rspec # hermetic — live examples are tagged :e2e and filtered out
234
+ WEFUNDER_E2E=1 bundle exec rspec spec/e2e # live sandbox; also needs WEFUNDER_CLIENT_ID / WEFUNDER_CLIENT_SECRET
225
235
  ```
226
236
 
237
+ The live group is opt-in by tag, not by path: without `WEFUNDER_E2E=1` it never runs, even when sandbox
238
+ credentials happen to be exported in your shell.
239
+
227
240
  Generated files in `lib/wefunder_generated/` come from `openapi/openapi.yaml` via `script/generate`
228
241
  (openapi-generator in Docker; no Java needed) and are never edited by hand.
229
242
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "time"
4
5
  require "uri"
5
6
  require "faraday"
6
7
 
@@ -36,11 +37,12 @@ module Wefunder
36
37
 
37
38
  _ = authorize_base_url # only /authorize uses it; see OAuth.create_authorization_url
38
39
  @mode = Wefunder.mode_for_token(token_set.access_token)
40
+ @timeout = timeout
39
41
  token_base = OAuth.resolve_token_base(token_base_url: token_base_url, oauth_base_url: oauth_base_url)
40
- re_mint = build_re_mint(client_credentials, client_id, client_secret, token_base, faraday, now)
42
+ re_mint = build_re_mint(client_credentials, client_id, client_secret, token_base, faraday, now, timeout)
41
43
  @token_manager = TokenManager.new(token_set, client_id: client_id, client_secret: client_secret, re_mint: re_mint,
42
44
  on_token_refresh: on_token_refresh, store: store, faraday: faraday, now: now,
43
- token_base_url: token_base)
45
+ token_base_url: token_base, timeout: timeout)
44
46
  @api_client = build_api_client(base_url, api_version, retry_options, faraday, now, sleep, random, timeout)
45
47
  @raw = Raw.new(@api_client)
46
48
  @users = Users.new(self)
@@ -59,7 +61,8 @@ module Wefunder
59
61
  def self.from_client_credentials(client_id:, client_secret:, scopes: nil, **opts)
60
62
  tokens = OAuth.client_credentials_grant(
61
63
  client_id: client_id, client_secret: client_secret, scopes: scopes,
62
- token_base_url: opts[:token_base_url], oauth_base_url: opts[:oauth_base_url], faraday: opts[:faraday], now: opts[:now]
64
+ token_base_url: opts[:token_base_url], oauth_base_url: opts[:oauth_base_url], faraday: opts[:faraday], now: opts[:now],
65
+ timeout: opts.fetch(:timeout, 30)
63
66
  )
64
67
  new(tokens: tokens, client_id: client_id, client_secret: client_secret, client_credentials: { scopes: scopes }, **opts)
65
68
  end
@@ -67,6 +70,9 @@ module Wefunder
67
70
  # The current token set (rotated refresh token included).
68
71
  def tokens = @token_manager.current
69
72
 
73
+ # The TokenManager (for +mark_persisted!+ after handling a TokenPersistenceError).
74
+ attr_reader :token_manager
75
+
70
76
  # The generated APIs, one accessor per tag (+wf.raw.syndicate_members.list_syndicate_members(id)+).
71
77
  # Wrap calls in +wrap+ to get typed errors.
72
78
  attr_reader :raw
@@ -88,7 +94,8 @@ module Wefunder
88
94
  payload = body.nil? || body.is_a?(String) ? body : JSON.generate(body)
89
95
  hdrs = @api_client.default_headers.merge(headers || {})
90
96
  response = conn.run_request(method.to_s.downcase.to_sym, path, payload, hdrs) do |req|
91
- req.params.update((query || {}).compact.transform_keys(&:to_s))
97
+ req.options.timeout = @timeout if @timeout # same timeout as the generated calls
98
+ req.params.update(Client.normalize_params((query || {}).compact).transform_keys(&:to_s))
92
99
  end
93
100
  if response.status >= 400
94
101
  raise Error.from_response(response.status, response.headers, response.body,
@@ -96,6 +103,22 @@ module Wefunder
96
103
  end
97
104
 
98
105
  response.body.to_s.empty? ? nil : JSON.parse(response.body)
106
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError => e
107
+ # Same shape the generated calls produce through `wrap` (ApiError with no code).
108
+ raise Error.new(status: 0, type: "network_error", message: e.message.to_s)
109
+ end
110
+
111
+ # Query values the API wants as ISO 8601: Time / DateTime → +iso8601+, Date → +YYYY-MM-DD+.
112
+ # Faraday would otherwise serialize a Time with +to_s+ ("2026-09-01 00:00:00 UTC"), which
113
+ # the server rejects with 400.
114
+ def self.normalize_params(opts)
115
+ opts.transform_values do |v|
116
+ case v
117
+ when Date then v.iso8601 # Date and DateTime (DateTime < Date)
118
+ when Time then v.utc.iso8601
119
+ else v
120
+ end
121
+ end
99
122
  end
100
123
 
101
124
  # Single-resource envelopes are +{data: Entity}+; return the entity for ergonomics.
@@ -107,13 +130,13 @@ module Wefunder
107
130
 
108
131
  private
109
132
 
110
- def build_re_mint(client_credentials, client_id, client_secret, token_base, faraday, now)
133
+ def build_re_mint(client_credentials, client_id, client_secret, token_base, faraday, now, timeout)
111
134
  return nil unless client_credentials && client_id && client_secret
112
135
 
113
136
  scopes = client_credentials[:scopes] || client_credentials["scopes"]
114
137
  lambda do
115
138
  OAuth.client_credentials_grant(client_id: client_id, client_secret: client_secret, scopes: scopes,
116
- token_base_url: token_base, faraday: faraday, now: now)
139
+ token_base_url: token_base, faraday: faraday, now: now, timeout: timeout)
117
140
  end
118
141
  end
119
142
 
@@ -172,9 +195,11 @@ module Wefunder
172
195
  def wrap(&) = @client.wrap(&)
173
196
  def raw = @client.raw
174
197
  def data_of(env) = Client.data_of(env)
198
+ def norm(opts) = Client.normalize_params(opts)
175
199
 
176
200
  # Auto-paginate a list method, keeping +opts+ on every page and threading the cursor.
177
201
  def pages(opts, &fetch)
202
+ opts = norm(opts)
178
203
  Wefunder.paginate { |cursor| wrap { fetch.call(cursor.nil? ? opts : opts.merge(cursor: cursor)) } }
179
204
  end
180
205
  end
@@ -186,7 +211,7 @@ module Wefunder
186
211
 
187
212
  class Offerings < Namespace
188
213
  # One page of GET /explore (+sort:+, +cursor:+, filters). Returns the envelope.
189
- def list(**opts) = wrap { raw.explore.list_offerings(opts) }
214
+ def list(**opts) = wrap { raw.explore.list_offerings(norm(opts)) }
190
215
  # Every offering, lazily, +opts+ preserved across pages.
191
216
  def all(**opts) = pages(opts) { |o| raw.explore.list_offerings(o) }
192
217
  def get(offering_id) = data_of(wrap { raw.explore.get_offering(offering_id) })
@@ -196,7 +221,7 @@ module Wefunder
196
221
  # The Investment Delta API. +list+ without a cursor bootstraps; +next_cursor+ is always
197
222
  # present (persist it after every sync) and +has_more+ terminates.
198
223
  class Investments < Namespace
199
- def list(**opts) = wrap { raw.investments.list_investments(opts) }
224
+ def list(**opts) = wrap { raw.investments.list_investments(norm(opts)) }
200
225
  def all(**opts) = pages(opts) { |o| raw.investments.list_investments(o) }
201
226
  def collect(**) = all(**).to_a
202
227
  def get(investment_id) = data_of(wrap { raw.investments.get_investment(investment_id) })
@@ -204,7 +229,7 @@ module Wefunder
204
229
 
205
230
  class Portfolio < Namespace
206
231
  Positions = Class.new(Namespace) do
207
- def list(**opts) = wrap { raw.portfolio.list_portfolio_positions(opts) }
232
+ def list(**opts) = wrap { raw.portfolio.list_portfolio_positions(norm(opts)) }
208
233
  def all(**opts) = pages(opts) { |o| raw.portfolio.list_portfolio_positions(o) }
209
234
  end
210
235
 
@@ -216,19 +241,22 @@ module Wefunder
216
241
  end
217
242
 
218
243
  # The portfolio summary (+status:+, +company:+ filters).
219
- def get(**opts) = data_of(wrap { raw.portfolio.get_portfolio(opts) })
244
+ def get(**opts) = data_of(wrap { raw.portfolio.get_portfolio(norm(opts)) })
220
245
  end
221
246
 
222
247
  class Campaigns < Namespace
223
- def list(**opts) = wrap { raw.campaigns.list_campaigns(opts) }
248
+ def list(**opts) = wrap { raw.campaigns.list_campaigns(norm(opts)) }
224
249
  def all(**opts) = pages(opts) { |o| raw.campaigns.list_campaigns(o) }
225
250
  end
226
251
 
227
252
  class Syndicates < Namespace
228
- def list(**opts) = wrap { raw.syndicates.list_syndicates(opts) }
253
+ def list(**opts) = wrap { raw.syndicates.list_syndicates(norm(opts)) }
229
254
  def all(**opts) = pages(opts) { |o| raw.syndicates.list_syndicates(o) }
230
255
  def get(syndicate_id) = data_of(wrap { raw.syndicates.get_syndicate(syndicate_id) })
231
- def portfolio(syndicate_id, **opts) = data_of(wrap { raw.syndicate_portfolio.get_syndicate_portfolio(syndicate_id, opts) })
256
+
257
+ def portfolio(syndicate_id, **opts)
258
+ data_of(wrap { raw.syndicate_portfolio.get_syndicate_portfolio(syndicate_id, norm(opts)) })
259
+ end
232
260
 
233
261
  def portfolio_positions(syndicate_id, **opts)
234
262
  pages(opts) { |o| raw.syndicate_portfolio.list_syndicate_portfolio_positions(syndicate_id, o) }
@@ -236,7 +264,7 @@ module Wefunder
236
264
  end
237
265
 
238
266
  class Intents < Namespace
239
- def list(**opts) = wrap { raw.intents.list_intents(opts) }
267
+ def list(**opts) = wrap { raw.intents.list_intents(norm(opts)) }
240
268
  def all(**opts) = pages(opts) { |o| raw.intents.list_intents(o) }
241
269
  def get(intent_id) = data_of(wrap { raw.intents.get_intent(intent_id) })
242
270
  def create(body) = data_of(wrap { raw.intents.create_intent(WefunderGenerated::CreateIntentRequest.new(body)) })
@@ -93,32 +93,35 @@ module Wefunder
93
93
 
94
94
  # Exchange an authorization code (+ PKCE verifier) for a token set. Public (PKCE) clients
95
95
  # omit +client_secret+; confidential clients pass it.
96
- def exchange_code(client_id:, code:, redirect_uri:, code_verifier:, client_secret: nil, faraday: nil, now: nil, **hosts)
96
+ def exchange_code(client_id:, code:, redirect_uri:, code_verifier:, client_secret: nil, faraday: nil, now: nil, timeout: nil,
97
+ **hosts)
97
98
  params = { grant_type: "authorization_code", client_id: client_id, code: code, redirect_uri: redirect_uri,
98
99
  code_verifier: code_verifier }
99
100
  params[:client_secret] = client_secret if client_secret
100
- post_token(resolve_token_base(**hosts), params, faraday, now)
101
+ post_token(resolve_token_base(**hosts), params, faraday, now, timeout)
101
102
  end
102
103
 
103
104
  # Mint an application token (server-to-server). cc tokens carry no refresh token.
104
- def client_credentials_grant(client_id:, client_secret:, scopes: nil, faraday: nil, now: nil, **hosts)
105
+ def client_credentials_grant(client_id:, client_secret:, scopes: nil, faraday: nil, now: nil, timeout: nil, **hosts)
105
106
  params = { grant_type: "client_credentials", client_id: client_id, client_secret: client_secret }
106
107
  params[:scope] = Array(scopes).join(" ") if scopes && !Array(scopes).empty?
107
- post_token(resolve_token_base(**hosts), params, faraday, now)
108
+ post_token(resolve_token_base(**hosts), params, faraday, now, timeout)
108
109
  end
109
110
 
110
111
  # Refresh an access token. CRITICAL: the result carries a NEW refresh token (rotation) —
111
112
  # persist it. Reusing the old one after rotation is a permanent 401.
112
- def refresh_token(client_id:, refresh_token:, client_secret: nil, faraday: nil, now: nil, **hosts)
113
+ def refresh_token(client_id:, refresh_token:, client_secret: nil, faraday: nil, now: nil, timeout: nil, **hosts)
113
114
  params = { grant_type: "refresh_token", client_id: client_id, refresh_token: refresh_token }
114
115
  params[:client_secret] = client_secret if client_secret
115
- post_token(resolve_token_base(**hosts), params, faraday, now)
116
+ post_token(resolve_token_base(**hosts), params, faraday, now, timeout)
116
117
  end
117
118
 
118
119
  # +faraday+ is an optional callable that configures the connection (tests inject an
119
- # adapter: +->(conn) { conn.adapter :test, stubs }+).
120
- def post_token(base, params, faraday, now)
120
+ # adapter: +->(conn) { conn.adapter :test, stubs }+). +timeout+ (seconds) covers the token
121
+ # round-trip so refreshes honour the client's timeout like every other call.
122
+ def post_token(base, params, faraday, now, timeout = nil)
121
123
  conn = Faraday.new do |c|
124
+ c.options.timeout = timeout if timeout
122
125
  faraday&.call(c)
123
126
  c.adapter Faraday.default_adapter unless faraday
124
127
  end
@@ -8,14 +8,29 @@ 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 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.
15
+ class TokenPersistenceError < StandardError
16
+ attr_reader :tokens
17
+
18
+ def initialize(tokens, cause)
19
+ super("Token store failed to save the rotated token set: #{cause.message}")
20
+ @tokens = tokens
21
+ end
22
+ end
23
+
11
24
  class TokenManager
12
25
  DEFAULT_EXPIRY_LEEWAY_SECONDS = 30.0
13
26
 
14
- 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
15
30
 
16
31
  def initialize(tokens, client_id: nil, client_secret: nil, re_mint: nil, on_token_refresh: nil, store: nil,
17
32
  faraday: nil, now: nil, expiry_leeway_seconds: DEFAULT_EXPIRY_LEEWAY_SECONDS,
18
- token_base_url: nil, oauth_base_url: nil)
33
+ token_base_url: nil, oauth_base_url: nil, timeout: nil)
19
34
  @current = tokens
20
35
  @client_id = client_id
21
36
  @client_secret = client_secret
@@ -23,6 +38,7 @@ module Wefunder
23
38
  @on_token_refresh = on_token_refresh
24
39
  @store = store
25
40
  @faraday = faraday
41
+ @timeout = timeout
26
42
  @now = now || -> { Time.now.to_f }
27
43
  @leeway = expiry_leeway_seconds
28
44
  @token_base_url = OAuth.resolve_token_base(token_base_url: token_base_url, oauth_base_url: oauth_base_url)
@@ -34,23 +50,42 @@ module Wefunder
34
50
  can_rotate? || !@re_mint.nil?
35
51
  end
36
52
 
37
- # 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.
38
55
  def access_token
56
+ @lock.synchronize { publish_pending! if @pending_tokens } if @pending_tokens # re-check under the lock
39
57
  refresh(stale_token: @current.access_token) if near_expiry? && can_refresh?
40
58
  @current.access_token
41
59
  end
42
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
+
43
75
  # Recover the token (after a 401 or proactively). If +stale_token+ is no longer the current
44
76
  # token, someone else already recovered and the current set is returned.
45
77
  def refresh(stale_token: nil)
46
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
47
82
  return @current if stale_token && @current.access_token != stale_token
48
83
 
49
84
  previous_refresh = @current.refresh_token
50
85
  nxt =
51
86
  if can_rotate?
52
87
  set = OAuth.refresh_token(client_id: @client_id, client_secret: @client_secret, refresh_token: previous_refresh,
53
- token_base_url: @token_base_url, faraday: @faraday, now: @now)
88
+ token_base_url: @token_base_url, faraday: @faraday, now: @now, timeout: @timeout)
54
89
  # Some servers omit a fresh refresh_token on rotation-disabled flows; keep the old one.
55
90
  set.refresh_token ||= previous_refresh
56
91
  set
@@ -59,15 +94,36 @@ module Wefunder
59
94
  else
60
95
  raise AuthError, "Access token expired and no refresh token / re-mint capability is configured."
61
96
  end
62
- @current = nxt
63
- @store&.save(nxt)
64
- @on_token_refresh&.call(nxt)
65
- nxt
97
+ @pending_tokens = nxt
98
+ publish_pending!
66
99
  end
67
100
  end
68
101
 
69
102
  private
70
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
119
+ @current = tokens
120
+ tokens
121
+ end
122
+
123
+ def same_token_set?(a, b)
124
+ a.access_token == b.access_token && a.refresh_token == b.refresh_token
125
+ end
126
+
71
127
  def can_rotate?
72
128
  !@current.refresh_token.nil? && !@current.refresh_token.empty? && !@client_id.nil?
73
129
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wefunder
4
- VERSION = "0.1.0.beta1"
4
+ VERSION = "0.1.0.beta3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wefunder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.beta1
4
+ version: 0.1.0.beta3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wefunder