@wefunder/sdk 0.1.0-beta.7 → 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,212 +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.
84
93
 
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:
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.
97
+
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.
112
+
113
+ ## Calling the API
114
+
115
+ Common resources are available through typed namespaces:
116
+
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`.
102
124
 
103
- OAuth uses two hosts, independently overridable:
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.
104
126
 
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`.
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.
107
128
 
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.
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
- ## Errors
149
+ Cursors are opaque. Pass the value returned by the API without modifying it.
136
150
 
137
- Failed requests throw `WefunderError` with the fields from the API's error envelope,
138
- including the `request_id` (read from the response body) — quote it in support tickets.
151
+ ## Errors and retries
152
+
153
+ API failures throw `WefunderError`:
139
154
 
140
155
  ```ts
141
156
  import { WefunderError } from "@wefunder/sdk";
142
157
 
143
158
  try {
144
- await wf.syndicates.get(123);
145
- } catch (err) {
146
- if (err instanceof WefunderError) {
147
- console.error(err.status, err.type, err.message, err.requestId);
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);
148
163
  }
149
164
  }
150
165
  ```
151
166
 
152
- Idempotent `GET`s are retried automatically on transient `5xx`/network errors and on
153
- `429` (honoring `X-RateLimit-Reset`). Writes are never auto-retried.
167
+ The SDK retries idempotent `GET` requests after transient network errors, `5xx` responses, and rate limits. Write requests are not retried automatically.
154
168
 
155
169
  ## Webhooks
156
170
 
157
- Verify and parse webhook deliveries. Pass the **raw** request body (not a re-serialized
158
- object) and the headers:
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.
159
176
 
160
177
  ```ts
161
- import { constructEvent } 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
+ });
162
183
 
163
- app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
184
+ await saveSecret(endpoint.attributes!.secret!);
185
+ ```
186
+
187
+ `wf.webhookEndpoints` also provides `list`, `get`, `update`, `remove`, `rotateSecret`, `reenable`, and `test`.
188
+
189
+ ### 2. Verify and handle deliveries
190
+
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.
192
+
193
+ ```ts
194
+ import { constructEvent, dispatchWebhook, WebhookSignatureError } from "@wefunder/sdk";
195
+
196
+ app.post("/webhooks/wefunder", express.raw({ type: "*/*" }), async (req, res) => {
164
197
  let event;
165
198
  try {
166
- event = constructEvent(req.body.toString("utf8"), req.headers, process.env.WEBHOOK_SECRET!);
167
- } catch {
168
- 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;
169
203
  }
170
- // event.event, event.deliveryId, event.data
171
- 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
+ });
172
212
  });
173
213
  ```
174
214
 
175
- ## 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.
176
218
 
177
- Ergonomic namespaces cover the common GA resources. Every generated operation is also
178
- available, pre-bound, under `wf.raw`. Raw ops return the low-level `{ data, error,
179
- response }` result; wrap them in `wf.unwrap(...)` to get the same typed-error +
180
- 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:
181
222
 
182
223
  ```ts
183
- const members = await wf.unwrap(wf.raw.listSyndicateMembers({ path: { syndicate_id: 1 } }));
224
+ import { signWebhook } from "@wefunder/sdk";
225
+
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}`
229
+ ```
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
184
236
 
185
- // Or handle the raw result yourself:
186
- const res = await wf.raw.listSyndicateMembers({ path: { syndicate_id: 1 } });
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
+ );
187
249
  ```
188
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
+
189
253
  ## Development
190
254
 
191
255
  ```bash
192
256
  npm install
193
- npm run generate # regenerate src/generated from spec/openapi.yaml
194
257
  npm run typecheck
195
- npm test # hermetic unit tests (no network)
196
- 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
197
260
  npm run build
198
261
  ```
199
262
 
200
- The live E2E hits `api.wefunder.com` with a sandbox app's `client_credentials`. Put
201
- 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.
202
264
 
203
- The typed layer in `src/generated/` is produced by `@hey-api/openapi-ts` from
204
- `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.
205
266
 
206
- ### Syncing the spec (maintainers)
267
+ ### Updating the API specification
207
268
 
208
- `spec/openapi.yaml` is a vendored copy of the **public tier** (stable + beta) of the
209
- canonical Wefunder swagger. Preview/internal operations are excluded by design. To
210
- refresh it from a local wefunder checkout:
269
+ Run the sync command against a local checkout of the Wefunder application, then regenerate the client:
211
270
 
212
271
  ```bash
213
- WEFUNDER_REPO=/path/to/wefunder npm run sync-spec
272
+ npm run sync-spec -- /path/to/wefunder
214
273
  npm run generate
215
- git add spec src/generated # commit both together
274
+ npm run typecheck
275
+ npm test
216
276
  ```
217
277
 
218
- `sync-spec` delegates filtering to the wefunder repo's own `build-filtered-spec.js`,
219
- so the public-tier definition can't drift between the two repos. CI's
220
- `generated code matches spec` job verifies `src/generated` matches the committed spec;
221
- it cannot reach the private canonical swagger, so run `sync-spec` before cutting a
222
- release. (`npm test` stays hermetic.)
278
+ Commit the specification and generated client together.
223
279
 
224
- ### Releasing (maintainers)
280
+ ### Releasing
225
281
 
226
- Releases publish from CI with provenance — no manual `npm publish` or OTP. Bump the
227
- version and push the tag; the `Release` workflow (`.github/workflows/release.yml`,
228
- triggered on `v*` tags) runs the `prepublishOnly` gate and publishes:
282
+ Releases are published by GitHub Actions. From a clean `main` branch:
229
283
 
230
284
  ```bash
231
- git checkout main && git pull # clean tree, on main
232
- npm version prerelease --preid beta # 0.1.0-beta.N → N+1, commits + tags v0.1.0-beta.N+1
233
- git push --follow-tags # pushes the commit + tag → CI publishes
285
+ npm version prerelease --preid beta
286
+ git push --follow-tags
234
287
  ```
235
288
 
236
- The workflow verifies the tag matches `package.json`, then publishes — prerelease
237
- versions (e.g. `0.1.0-beta.N`) to the **`beta`** dist-tag (npm requires an explicit tag
238
- for prereleases), stable versions to `latest`. For a stable release use
239
- `npm version patch|minor|major` (no `--preid`).
240
-
241
- **Auth is npm Trusted Publishing (OIDC) — no token to store.** npm exchanges the
242
- workflow's GitHub OIDC token for a short-lived credential at publish time, and
243
- provenance is generated automatically (verified-build badge on npmjs.com).
244
-
245
- **One-time setup:** on npmjs.com, `@wefunder/sdk` → **Settings → Trusted Publishing →
246
- GitHub Actions**, with org/user `Wefunder`, repository `wefunder-node`, workflow
247
- `release.yml` (leave Environment blank). No repo secret needed. (Requires this public
248
- repo + public package — both true.)
289
+ The release workflow runs the package checks and publishes prereleases to npm's `beta` tag.