@wefunder/sdk 0.1.0-beta.8 → 0.1.0-beta.9

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.
package/README.md CHANGED
@@ -1,32 +1,29 @@
1
- # @wefunder/sdk (beta)
1
+ # @wefunder/sdk
2
2
 
3
3
  [![CI](https://github.com/Wefunder/wefunder-node/actions/workflows/ci.yml/badge.svg)](https://github.com/Wefunder/wefunder-node/actions/workflows/ci.yml)
4
4
 
5
- Official TypeScript SDK for the [Wefunder API](https://docs.wefunder.com/api-reference).
5
+ The official TypeScript SDK for the [Wefunder API](https://docs.wefunder.com/api-reference).
6
6
 
7
- > **Beta.** The package is `0.x` — breaking changes are possible while we
8
- > stabilize. Feedback welcome.
7
+ The SDK is currently in beta. Releases may include breaking changes until the API reaches `1.0`.
8
+
9
+ ## Install
9
10
 
10
11
  ```bash
11
12
  npm install @wefunder/sdk@beta
12
13
  ```
13
14
 
14
- While in beta, releases publish to the `@beta` dist-tag (npm reserves `latest` for the
15
- eventual stable release). Node 20+ (uses the global `fetch`). ESM and CommonJS both supported.
15
+ Node 20 or newer is required. Both ESM and CommonJS are supported.
16
+
17
+ ## Authentication
16
18
 
17
- ### Scope & versioning
19
+ Wefunder supports two OAuth grants:
18
20
 
19
- - **Surface:** this release covers the **stable + beta** public API (offerings,
20
- investments, campaigns, syndicates, intents, attribution). Preview-only endpoints
21
- (the partner SPV / sandbox-simulation surface) are intentionally **not** included yet.
22
- - **API version:** the SDK sends `Wefunder-Version: 2025-01-15` on every request,
23
- forward-compatible with Wefunder's dated-version model. **The API does not resolve
24
- this header yet**, so version pinning is not enforced server-side until that ships —
25
- the header is correct in shape and will start taking effect transparently.
21
+ - Use `client_credentials` for server-to-server access to public data.
22
+ - Use `authorization_code` with PKCE when acting on behalf of a user.
26
23
 
27
- ## Quickstart (server-to-server)
24
+ ### Server-to-server
28
25
 
29
- The fastest path: a `client_credentials` grant with a sandbox token, no user redirect.
26
+ Create a client with your application's client ID and secret:
30
27
 
31
28
  ```ts
32
29
  import { Wefunder } from "@wefunder/sdk";
@@ -37,226 +34,256 @@ const wf = await Wefunder.fromClientCredentials({
37
34
  scopes: ["read:public"],
38
35
  });
39
36
 
40
- // A client_credentials token can only hold `read:public` — it acts as your app,
41
- // with no user. So it can browse public offerings, but NOT user-scoped data.
42
37
  const page = await wf.offerings.list();
43
- console.log(`${page.data?.length} offerings`);
44
38
  ```
45
39
 
46
- > **`wf.users.me()` won't work with `client_credentials`.** `/users/me` requires
47
- > `read:profile`, a user-context scope — calling it with a `client_credentials`
48
- > token throws `WefunderError` (`403 insufficient_scope`). To read user data, use
49
- > the `authorization_code` + PKCE flow below and request `read:profile`.
50
-
51
- ## Authentication
40
+ Client-credentials tokens represent the application, not a user. They cannot be used for user-scoped endpoints such as `wf.users.me()` or `wf.portfolio.get()`.
52
41
 
53
- The SDK supports both OAuth 2.0 grants the API offers.
42
+ The SDK obtains a new token automatically when a client-credentials token expires.
54
43
 
55
- ### `client_credentials` (server-side)
44
+ ### User authorization with PKCE
56
45
 
57
- `Wefunder.fromClientCredentials({ clientId, clientSecret, scopes })` — see above.
58
- These tokens are short-lived and have no refresh token, but the client keeps the
59
- grant inputs and **auto-re-mints** on expiry or a `401` — so a long-lived server can
60
- hold one `wf` and never hand-roll token recovery.
61
-
62
- ### `authorization_code` + PKCE (acting on behalf of a user)
46
+ Generate the authorization URL on your server. Store the state and PKCE verifier in the user's session before redirecting them:
63
47
 
64
48
  ```ts
65
- import { generatePkce, createAuthorizationUrl, exchangeCode, Wefunder } from "@wefunder/sdk";
49
+ import { createAuthorizationUrl, generatePkce } from "@wefunder/sdk";
50
+ import { randomBytes } from "node:crypto";
66
51
 
67
- // 1. Before redirecting, generate PKCE + a state token and stash them in the session.
68
52
  const pkce = generatePkce();
69
- const url = createAuthorizationUrl({
70
- clientId, redirectUri, scopes: ["read:investments"], state, pkce,
53
+ const state = randomBytes(32).toString("base64url");
54
+
55
+ await saveOAuthAttempt({ state, codeVerifier: pkce.codeVerifier });
56
+
57
+ const authorizationUrl = createAuthorizationUrl({
58
+ clientId,
59
+ redirectUri,
60
+ scopes: ["read:investments"],
61
+ state,
62
+ pkce,
71
63
  });
72
- // redirect the user to `url`
64
+ ```
65
+
66
+ On the callback, validate the state and exchange the authorization code:
67
+
68
+ ```ts
69
+ import { exchangeCode, Wefunder } from "@wefunder/sdk";
70
+
71
+ const attempt = await consumeOAuthAttempt(state);
72
+ if (!attempt) throw new Error("Invalid OAuth state");
73
73
 
74
- // 2. On the callback, exchange the code (+ verifier) for tokens.
75
74
  const tokens = await exchangeCode({
76
- clientId, code, redirectUri, codeVerifier: pkce.codeVerifier,
75
+ clientId,
76
+ clientSecret, // optional for public clients
77
+ code,
78
+ redirectUri,
79
+ codeVerifier: attempt.codeVerifier,
77
80
  });
78
81
 
79
- // 3. Build a client. Pass clientId so it can auto-refresh on expiry.
80
- const wf = new Wefunder({ tokens, clientId, onTokenRefresh: (t) => saveToDb(t) });
82
+ const wf = new Wefunder({
83
+ tokens,
84
+ clientId,
85
+ clientSecret,
86
+ store: {
87
+ save: (nextTokens) => saveTokens(nextTokens),
88
+ },
89
+ });
81
90
  ```
82
91
 
83
- ### Refresh tokens rotate — persist every refresh
92
+ Keep access tokens, refresh tokens, OAuth state, and PKCE verifiers on the server. Encrypt persisted tokens at rest.
93
+
94
+ ### Refresh tokens
95
+
96
+ Refresh tokens rotate. When the SDK refreshes an access token, it calls `store.save()` with the new token set before continuing the request. Persist the entire token set each time.
84
97
 
85
- Wefunder **rotates** refresh tokens: each refresh returns a *new* refresh token and
86
- invalidates the old one. The SDK refreshes automatically (proactively before expiry,
87
- and on a `401`), coalescing concurrent refreshes into one. You just have to persist
88
- the rotated token so it survives a restart:
98
+ Load the saved tokens yourself when constructing a client after a process restart:
89
99
 
90
100
  ```ts
101
+ const tokens = await loadTokens();
102
+
91
103
  const wf = new Wefunder({
92
104
  tokens,
93
105
  clientId,
94
- store: {
95
- load: () => db.loadTokens(),
96
- save: (t) => db.saveTokens(t), // called on every rotation
97
- },
106
+ clientSecret,
107
+ store: { save: saveTokens },
98
108
  });
99
109
  ```
100
110
 
101
- ### Hosts (advanced)
111
+ If several application instances can use the same OAuth connection, serialize refreshes for that connection. This prevents two instances from trying to rotate the same refresh token at once.
102
112
 
103
- OAuth uses two hosts, independently overridable:
113
+ ## Calling the API
104
114
 
105
- - **token host** — `/token` + refresh (`fromClientCredentials`, `exchangeCode`, refresh). Defaults to `https://api.wefunder.com/oauth`. The edge gateway routes by the credential's mode (a `pk_test_` grant mints in sandbox, `pk_live_` in live), so one host serves both. Override via `tokenBaseUrl` (or set both with `oauthBaseUrl`).
106
- - **authorize host** — the browser consent redirect (`createAuthorizationUrl`). Picked from the `client_id`: `pk_test_` → `https://oauth.wefunder-sandbox.com/oauth` (sandbox consent), otherwise `https://wefunder.com/oauth` (live). Override via `authorizeBaseUrl`.
115
+ Common resources are available through typed namespaces:
107
116
 
108
- The **API base** is `WefunderOptions.baseUrl` (default `https://api.wefunder.com`, version-free). The API version is pinned by the `Wefunder-Version` header, not the path; `/api/v2` remains a working back-compat alias.
117
+ ```ts
118
+ const offerings = await wf.offerings.list({ sort: "newest" });
119
+ const investments = await wf.investments.list({ company_id: "co_example" });
120
+ const portfolio = await wf.portfolio.get();
121
+ ```
122
+
123
+ Namespaces: `users`, `offerings`, `investments`, `portfolio`, `campaigns`, `syndicates`, `intents`, `attribution`, and `webhookEndpoints`.
124
+
125
+ `wf.investments` is the Investment Delta API. `list()` without a cursor bootstraps; pass `updated_since` or the `meta.next_cursor` you saved from your last page to receive only records that changed since then. `next_cursor` is always present, even on the final page, so persist it after every sync.
126
+
127
+ The methods available to a client depend on its OAuth scopes. Consult the [API reference](https://docs.wefunder.com/api-reference) for the scope required by each endpoint.
128
+
129
+ The API base URL is `https://api.wefunder.com`. Paths are version-free; the SDK sends the API version in the `Wefunder-Version` request header.
109
130
 
110
131
  ## Pagination
111
132
 
112
- List endpoints auto-paginate. The cursor is opaque — you never construct it. List
113
- methods take the endpoint's documented query params, and they're preserved across pages.
133
+ List namespaces provide three ways to work with paginated results:
114
134
 
115
135
  ```ts
116
- // Stream lazily (one page fetched at a time):
117
- for await (const inv of wf.investments.all()) {
118
- console.log(inv.id);
119
- }
136
+ // Fetch one page and inspect its cursor.
137
+ const page = await wf.offerings.list({ sort: "newest" });
138
+ console.log(page.data, page.meta?.next_cursor);
120
139
 
121
- // Query params are forwarded — e.g. sort the offerings browser (sort is preserved
122
- // on every page):
140
+ // Fetch pages lazily.
123
141
  for await (const offering of wf.offerings.all({ sort: "most_raised" })) {
124
142
  console.log(offering.id);
125
143
  }
126
144
 
127
- // Or collect everything:
128
- const all = await wf.investments.collect();
129
-
130
- // Or drive pages yourself (gives you `meta`):
131
- const page = await wf.offerings.list({ sort: "newest" });
132
- console.log(page.data, page.meta?.next_cursor);
145
+ // Fetch all results into an array.
146
+ const investments = await wf.investments.collect();
133
147
  ```
134
148
 
135
- ## Portfolio
149
+ Cursors are opaque. Pass the value returned by the API without modifying it.
136
150
 
137
- Portfolio endpoints require an authorization-code token with
138
- `read:investments`.
151
+ ## Errors and retries
152
+
153
+ API failures throw `WefunderError`:
139
154
 
140
155
  ```ts
141
- const summary = await wf.portfolio.get();
142
- console.log(summary.attributes?.total_current_value_cents);
156
+ import { WefunderError } from "@wefunder/sdk";
143
157
 
144
- for await (const position of wf.portfolio.positions.all({ status: "active" })) {
145
- console.log(position.id, position.attributes?.current_value_cents);
158
+ try {
159
+ await wf.syndicates.get("syn_example");
160
+ } catch (error) {
161
+ if (error instanceof WefunderError) {
162
+ console.error(error.status, error.type, error.message, error.requestId);
163
+ }
146
164
  }
147
165
  ```
148
166
 
149
- ## Errors
167
+ The SDK retries idempotent `GET` requests after transient network errors, `5xx` responses, and rate limits. Write requests are not retried automatically.
168
+
169
+ ## Webhooks
150
170
 
151
- Failed requests throw `WefunderError` with the fields from the API's error envelope,
152
- including the `request_id` (read from the response body) — quote it in support tickets.
171
+ Webhooks deliver platform events (`investment.executed`, `offering.opened`, `investment.changed`, …) to an HTTPS endpoint you register. Every delivery is signed; the SDK verifies the signature, parses the envelope, and gives you a typed event.
172
+
173
+ ### 1. Register an endpoint
174
+
175
+ Endpoints belong to your application and are managed through the live API (scope `write:webhooks`, org owner/admin/developer role). The signing secret is returned only on create and rotate, so store it immediately.
153
176
 
154
177
  ```ts
155
- import { WefunderError } from "@wefunder/sdk";
178
+ const endpoint = await wf.webhookEndpoints.create({
179
+ url: "https://yourapp.com/webhooks/wefunder", // public HTTPS; localhost and private IPs are rejected
180
+ events: ["offering.opened", "investment.executed"],
181
+ mode: "live", // "test" endpoints receive sandbox events
182
+ });
156
183
 
157
- try {
158
- await wf.syndicates.get(123);
159
- } catch (err) {
160
- if (err instanceof WefunderError) {
161
- console.error(err.status, err.type, err.message, err.requestId);
162
- }
163
- }
184
+ await saveSecret(endpoint.attributes!.secret!);
164
185
  ```
165
186
 
166
- Idempotent `GET`s are retried automatically on transient `5xx`/network errors and on
167
- `429` (honoring `X-RateLimit-Reset`). Writes are never auto-retried.
187
+ `wf.webhookEndpoints` also provides `list`, `get`, `update`, `remove`, `rotateSecret`, `reenable`, and `test`.
168
188
 
169
- ## Webhooks
189
+ ### 2. Verify and handle deliveries
170
190
 
171
- Verify and parse webhook deliveries. Pass the **raw** request body (not a re-serialized
172
- object) and the headers:
191
+ Pass the raw request body, the request headers, and your secret to `constructEvent`. It throws `WebhookSignatureError` (with a `reason`) when a delivery is not authentic.
173
192
 
174
193
  ```ts
175
- import { constructEvent } from "@wefunder/sdk";
194
+ import { constructEvent, dispatchWebhook, WebhookSignatureError } from "@wefunder/sdk";
176
195
 
177
- app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
196
+ app.post("/webhooks/wefunder", express.raw({ type: "*/*" }), async (req, res) => {
178
197
  let event;
179
198
  try {
180
- event = constructEvent(req.body.toString("utf8"), req.headers, process.env.WEBHOOK_SECRET!);
181
- } catch {
182
- return res.status(400).send("invalid signature");
199
+ event = constructEvent(req.body, req.headers, process.env.WEFUNDER_WEBHOOK_SECRET!);
200
+ } catch (err) {
201
+ if (err instanceof WebhookSignatureError) return res.status(400).send(err.reason);
202
+ throw err;
183
203
  }
184
- // event.event, event.deliveryId, event.data
185
- res.sendStatus(200);
204
+
205
+ res.sendStatus(200); // acknowledge first, then do the work
206
+
207
+ await dispatchWebhook(event, {
208
+ "investment.executed": async (e) => recordFunding(e.data.id, e.data.amounts.committed),
209
+ "offering.opened": async (e) => announce(e.data.company.name),
210
+ default: (e) => console.log("unhandled", e.event),
211
+ });
186
212
  });
187
213
  ```
188
214
 
189
- ## Escape hatch: `wf.raw`
215
+ `event` is a discriminated union, so narrowing on `event.event` types `event.data` for you. For fetch-style servers (Next.js route handlers, Hono, Cloudflare Workers), use `constructEventFromRequest(request, secret)` instead.
216
+
217
+ Deliveries are at-least-once and unordered. Deduplicate on `event.id`, and where a payload carries `occurred_at`, keep the state from the latest one you have seen.
190
218
 
191
- Ergonomic namespaces cover the common GA resources. Every generated operation is also
192
- available, pre-bound, under `wf.raw`. Raw ops return the low-level `{ data, error,
193
- response }` result; wrap them in `wf.unwrap(...)` to get the same typed-error +
194
- envelope handling the namespaces use (a `WefunderError` with `request_id` on failure):
219
+ ### 3. Test your handler
220
+
221
+ `wf.webhookEndpoints.test(endpoint.id)` sends a real, signed example event to your endpoint and reports the outcome inline. To unit-test your handler without the API, sign a fixture yourself:
195
222
 
196
223
  ```ts
197
- const members = await wf.unwrap(wf.raw.listSyndicateMembers({ path: { syndicate_id: 1 } }));
224
+ import { signWebhook } from "@wefunder/sdk";
198
225
 
199
- // Or handle the raw result yourself:
200
- const res = await wf.raw.listSyndicateMembers({ path: { syndicate_id: 1 } });
226
+ const body = JSON.stringify({ id: "evt_1", event: "offering.opened", created_at: "…", mode: "test", data: {…} });
227
+ const header = signWebhook({ payload: body, secret });
228
+ // POST `body` to your handler with `Wefunder-Signature: ${header}`
201
229
  ```
202
230
 
231
+ ### Secret rotation
232
+
233
+ `wf.webhookEndpoints.rotateSecret(id)` returns a new secret; the old one keeps signing for 24 hours, and deliveries carry a `v1` for each. `constructEvent` accepts either, so you can roll the new secret out to your servers without dropping an event.
234
+
235
+ ### Signature scheme
236
+
237
+ Each delivery carries `Wefunder-Signature: t=<unix seconds>,v1=<hex>` where `v1` is `HMAC-SHA256(secret, "<t>.<raw body>")`. Requests whose `t` is more than five minutes from now are rejected (`toleranceSeconds` adjusts this). `verifyWebhook` and `checkWebhookSignature` expose the check without parsing, and `constructEvent` still accepts the retired attribution-webhook headers (`X-Wefunder-Signature`/`X-Wefunder-Timestamp`).
238
+
239
+ ## Generated operations
240
+
241
+ Typed namespaces cover the most common resources. Every operation in the public OpenAPI specification is also available under `wf.raw`.
242
+
243
+ ```ts
244
+ const members = await wf.unwrap(
245
+ wf.raw.listSyndicateMembers({
246
+ path: { syndicate_id: "syn_example" },
247
+ }),
248
+ );
249
+ ```
250
+
251
+ Raw operations return `{ data, error, response }`. Passing the result to `wf.unwrap()` applies the same error handling used by the resource namespaces.
252
+
203
253
  ## Development
204
254
 
205
255
  ```bash
206
256
  npm install
207
- npm run generate # regenerate src/generated from spec/openapi.yaml
208
257
  npm run typecheck
209
- npm test # hermetic unit tests (no network)
210
- npm run test:e2e # live sandbox E2E — needs WEFUNDER_CLIENT_ID/SECRET (or a .env); auto-skips otherwise
258
+ npm run typecheck:examples
259
+ npm test
211
260
  npm run build
212
261
  ```
213
262
 
214
- The live E2E hits `api.wefunder.com` with a sandbox app's `client_credentials`. Put
215
- the credentials in a gitignored `.env` (`WEFUNDER_CLIENT_ID=` / `WEFUNDER_CLIENT_SECRET=`).
263
+ `npm run test:e2e` runs against the sandbox when `WEFUNDER_CLIENT_ID` and `WEFUNDER_CLIENT_SECRET` are set.
216
264
 
217
- The typed layer in `src/generated/` is produced by `@hey-api/openapi-ts` from
218
- `spec/openapi.yaml` and is never hand-edited. The hand-written shell in `src/` wraps it.
265
+ Generated files in `src/generated/` come from `spec/openapi.yaml` and should not be edited by hand.
219
266
 
220
- ### Syncing the spec (maintainers)
267
+ ### Updating the API specification
221
268
 
222
- `spec/openapi.yaml` is a vendored copy of the **public tier** (stable + beta) of the
223
- canonical Wefunder swagger. Preview/internal operations are excluded by design. To
224
- refresh it from a local wefunder checkout:
269
+ Run the sync command against a local checkout of the Wefunder application, then regenerate the client:
225
270
 
226
271
  ```bash
227
- WEFUNDER_REPO=/path/to/wefunder npm run sync-spec
272
+ npm run sync-spec -- /path/to/wefunder
228
273
  npm run generate
229
- git add spec src/generated # commit both together
274
+ npm run typecheck
275
+ npm test
230
276
  ```
231
277
 
232
- `sync-spec` delegates filtering to the wefunder repo's own `build-filtered-spec.js`,
233
- so the public-tier definition can't drift between the two repos. CI's
234
- `generated code matches spec` job verifies `src/generated` matches the committed spec;
235
- it cannot reach the private canonical swagger, so run `sync-spec` before cutting a
236
- release. (`npm test` stays hermetic.)
278
+ Commit the specification and generated client together.
237
279
 
238
- ### Releasing (maintainers)
280
+ ### Releasing
239
281
 
240
- Releases publish from CI with provenance — no manual `npm publish` or OTP. Bump the
241
- version and push the tag; the `Release` workflow (`.github/workflows/release.yml`,
242
- triggered on `v*` tags) runs the `prepublishOnly` gate and publishes:
282
+ Releases are published by GitHub Actions. From a clean `main` branch:
243
283
 
244
284
  ```bash
245
- git checkout main && git pull # clean tree, on main
246
- npm version prerelease --preid beta # 0.1.0-beta.N → N+1, commits + tags v0.1.0-beta.N+1
247
- git push --follow-tags # pushes the commit + tag → CI publishes
285
+ npm version prerelease --preid beta
286
+ git push --follow-tags
248
287
  ```
249
288
 
250
- The workflow verifies the tag matches `package.json`, then publishes — prerelease
251
- versions (e.g. `0.1.0-beta.N`) to the **`beta`** dist-tag (npm requires an explicit tag
252
- for prereleases), stable versions to `latest`. For a stable release use
253
- `npm version patch|minor|major` (no `--preid`).
254
-
255
- **Auth is npm Trusted Publishing (OIDC) — no token to store.** npm exchanges the
256
- workflow's GitHub OIDC token for a short-lived credential at publish time, and
257
- provenance is generated automatically (verified-build badge on npmjs.com).
258
-
259
- **One-time setup:** on npmjs.com, `@wefunder/sdk` → **Settings → Trusted Publishing →
260
- GitHub Actions**, with org/user `Wefunder`, repository `wefunder-node`, workflow
261
- `release.yml` (leave Environment blank). No repo secret needed. (Requires this public
262
- repo + public package — both true.)
289
+ The release workflow runs the package checks and publishes prereleases to npm's `beta` tag.