@wefunder/sdk 0.1.0-beta.1 → 0.1.0-beta.10

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,31 +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
- npm install @wefunder/sdk
12
+ npm install @wefunder/sdk@beta
12
13
  ```
13
14
 
14
- Node 18+ (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
15
18
 
16
- ### Scope & versioning
19
+ Wefunder supports two OAuth grants:
17
20
 
18
- - **Surface:** this release covers the **stable + beta** public API (offerings,
19
- investments, campaigns, syndicates, intents, attribution). Preview-only endpoints
20
- (the partner SPV / sandbox-simulation surface) are intentionally **not** included yet.
21
- - **API version:** the SDK sends `Wefunder-Version: 2025-01-15` on every request,
22
- forward-compatible with Wefunder's dated-version model. **The API does not resolve
23
- this header yet**, so version pinning is not enforced server-side until that ships —
24
- 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.
25
23
 
26
- ## Quickstart (server-to-server)
24
+ ### Server-to-server
27
25
 
28
- 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:
29
27
 
30
28
  ```ts
31
29
  import { Wefunder } from "@wefunder/sdk";
@@ -36,186 +34,264 @@ const wf = await Wefunder.fromClientCredentials({
36
34
  scopes: ["read:public"],
37
35
  });
38
36
 
39
- // A client_credentials token can only hold `read:public` — it acts as your app,
40
- // with no user. So it can browse public offerings, but NOT user-scoped data.
41
37
  const page = await wf.offerings.list();
42
- console.log(`${page.data?.length} offerings`);
43
38
  ```
44
39
 
45
- > **`wf.users.me()` won't work with `client_credentials`.** `/users/me` requires
46
- > `read:profile`, a user-context scope — calling it with a `client_credentials`
47
- > token throws `WefunderError` (`403 insufficient_scope`). To read user data, use
48
- > the `authorization_code` + PKCE flow below and request `read:profile`.
49
-
50
- ## 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()`.
51
41
 
52
- 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.
53
43
 
54
- ### `client_credentials` (server-side)
44
+ ### User authorization with PKCE
55
45
 
56
- `Wefunder.fromClientCredentials({ clientId, clientSecret, scopes })` — see above.
57
- These tokens are short-lived and have no refresh token, but the client keeps the
58
- grant inputs and **auto-re-mints** on expiry or a `401` — so a long-lived server can
59
- hold one `wf` and never hand-roll token recovery.
60
-
61
- ### `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:
62
47
 
63
48
  ```ts
64
- import { generatePkce, createAuthorizationUrl, exchangeCode, Wefunder } from "@wefunder/sdk";
49
+ import { createAuthorizationUrl, generatePkce } from "@wefunder/sdk";
50
+ import { randomBytes } from "node:crypto";
65
51
 
66
- // 1. Before redirecting, generate PKCE + a state token and stash them in the session.
67
52
  const pkce = generatePkce();
68
- const url = createAuthorizationUrl({
69
- 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,
70
63
  });
71
- // 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");
72
73
 
73
- // 2. On the callback, exchange the code (+ verifier) for tokens.
74
74
  const tokens = await exchangeCode({
75
- clientId, code, redirectUri, codeVerifier: pkce.codeVerifier,
75
+ clientId,
76
+ clientSecret, // optional for public clients
77
+ code,
78
+ redirectUri,
79
+ codeVerifier: attempt.codeVerifier,
76
80
  });
77
81
 
78
- // 3. Build a client. Pass clientId so it can auto-refresh on expiry.
79
- 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
+ });
80
90
  ```
81
91
 
82
- ### 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.
83
97
 
84
- Wefunder **rotates** refresh tokens: each refresh returns a *new* refresh token and
85
- invalidates the old one. The SDK refreshes automatically (proactively before expiry,
86
- and on a `401`), coalescing concurrent refreshes into one. You just have to persist
87
- the rotated token so it survives a restart:
98
+ Load the saved tokens yourself when constructing a client after a process restart:
88
99
 
89
100
  ```ts
101
+ const tokens = await loadTokens();
102
+
90
103
  const wf = new Wefunder({
91
104
  tokens,
92
105
  clientId,
93
- store: {
94
- load: () => db.loadTokens(),
95
- save: (t) => db.saveTokens(t), // called on every rotation
96
- },
106
+ clientSecret,
107
+ store: { save: saveTokens },
97
108
  });
98
109
  ```
99
110
 
100
- ### 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`.
101
124
 
102
- 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.
103
126
 
104
- - **authorize host** — the browser consent redirect (`createAuthorizationUrl`). Defaults to `https://wefunder.com/oauth`.
105
- - **token host** — `/token` + refresh (`fromClientCredentials`, `exchangeCode`, refresh). Defaults to `https://wefunder.com/oauth` today; it will move to `https://api.wefunder.com/oauth` when Wefunder's edge gateway ships. Override via `tokenBaseUrl` (or set both at once with `oauthBaseUrl`).
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.
106
128
 
107
- The **API base** is `WefunderOptions.baseUrl` (default `https://api.wefunder.com/api/v2`). When Wefunder ships version-free URLs, the canonical base drops `/api/v2`; the current path stays as a back-compat alias, so no change is required on your side.
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.
108
130
 
109
131
  ## Pagination
110
132
 
111
- List endpoints auto-paginate. The cursor is opaque — you never construct it. List
112
- 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:
113
134
 
114
135
  ```ts
115
- // Stream lazily (one page fetched at a time):
116
- for await (const inv of wf.investments.all()) {
117
- console.log(inv.id);
118
- }
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);
119
139
 
120
- // Query params are forwarded — e.g. sort the offerings browser (sort is preserved
121
- // on every page):
140
+ // Fetch pages lazily.
122
141
  for await (const offering of wf.offerings.all({ sort: "most_raised" })) {
123
142
  console.log(offering.id);
124
143
  }
125
144
 
126
- // Or collect everything:
127
- const all = await wf.investments.collect();
128
-
129
- // Or drive pages yourself (gives you `meta`):
130
- const page = await wf.offerings.list({ sort: "newest" });
131
- console.log(page.data, page.meta?.next_cursor);
145
+ // Fetch all results into an array.
146
+ const investments = await wf.investments.collect();
132
147
  ```
133
148
 
134
- ## Errors
149
+ Cursors are opaque. Pass the value returned by the API without modifying it.
150
+
151
+ ## Errors and retries
135
152
 
136
- Failed requests throw `WefunderError` with the fields from the API's error envelope,
137
- including the `request_id` (read from the response body) — quote it in support tickets.
153
+ API failures throw `WefunderError`:
138
154
 
139
155
  ```ts
140
156
  import { WefunderError } from "@wefunder/sdk";
141
157
 
142
158
  try {
143
- await wf.syndicates.get(123);
144
- } catch (err) {
145
- if (err instanceof WefunderError) {
146
- 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);
147
163
  }
148
164
  }
149
165
  ```
150
166
 
151
- Idempotent `GET`s are retried automatically on transient `5xx`/network errors and on
152
- `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.
153
168
 
154
169
  ## Webhooks
155
170
 
156
- Verify and parse webhook deliveries. Pass the **raw** request body (not a re-serialized
157
- 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.
176
+
177
+ ```ts
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
+ });
183
+
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.
158
192
 
159
193
  ```ts
160
- import { constructEvent } from "@wefunder/sdk";
194
+ import { constructEvent, dispatchWebhook, WebhookSignatureError } from "@wefunder/sdk";
161
195
 
162
- app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
196
+ app.post("/webhooks/wefunder", express.raw({ type: "*/*" }), async (req, res) => {
163
197
  let event;
164
198
  try {
165
- event = constructEvent(req.body.toString("utf8"), req.headers, process.env.WEBHOOK_SECRET!);
166
- } catch {
167
- 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;
168
203
  }
169
- // event.event, event.deliveryId, event.data
170
- 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
+ });
171
212
  });
172
213
  ```
173
214
 
174
- ## 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.
175
218
 
176
- Ergonomic namespaces cover the common GA resources. Every generated operation is also
177
- available, pre-bound, under `wf.raw`. Raw ops return the low-level `{ data, error,
178
- response }` result; wrap them in `wf.unwrap(...)` to get the same typed-error +
179
- 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:
180
222
 
181
223
  ```ts
182
- 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
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`).
183
238
 
184
- // Or handle the raw result yourself:
185
- const res = await wf.raw.listSyndicateMembers({ path: { syndicate_id: 1 } });
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
+ );
186
249
  ```
187
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
+
188
253
  ## Development
189
254
 
190
255
  ```bash
191
256
  npm install
192
- npm run generate # regenerate src/generated from spec/openapi.yaml
193
257
  npm run typecheck
194
- npm test # hermetic unit tests (no network)
195
- 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
196
260
  npm run build
197
261
  ```
198
262
 
199
- The live E2E hits `api.wefunder.com` with a sandbox app's `client_credentials`. Put
200
- 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.
201
264
 
202
- The typed layer in `src/generated/` is produced by `@hey-api/openapi-ts` from
203
- `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.
204
266
 
205
- ### Syncing the spec (maintainers)
267
+ ### Conformance vectors
206
268
 
207
- `spec/openapi.yaml` is a vendored copy of the **public tier** (stable + beta) of the
208
- canonical Wefunder swagger. Preview/internal operations are excluded by design. To
209
- refresh it from a local wefunder checkout:
269
+ `conformance/*.json` is the cross-language behavioural contract shared with the Python and Ruby
270
+ SDKs (signatures, token rotation, pagination, retries, errors). `test/conformance.test.ts` runs
271
+ every case; the vectors are frozen — fix the shell, not the vector. After deliberately changing
272
+ one, run `npm run build:conformance` to refresh `conformance/manifest.json`. See
273
+ [`conformance/README.md`](conformance/README.md).
274
+
275
+ ### Updating the API specification
276
+
277
+ Run the sync command against a local checkout of the Wefunder application, then regenerate the client:
210
278
 
211
279
  ```bash
212
- WEFUNDER_REPO=/path/to/wefunder npm run sync-spec
280
+ npm run sync-spec -- /path/to/wefunder
213
281
  npm run generate
214
- git add spec src/generated # commit both together
282
+ npm run typecheck
283
+ npm test
284
+ ```
285
+
286
+ Commit the specification and generated client together.
287
+
288
+ ### Releasing
289
+
290
+ Releases are published by GitHub Actions. From a clean `main` branch:
291
+
292
+ ```bash
293
+ npm version prerelease --preid beta
294
+ git push --follow-tags
215
295
  ```
216
296
 
217
- `sync-spec` delegates filtering to the wefunder repo's own `build-filtered-spec.js`,
218
- so the public-tier definition can't drift between the two repos. CI's
219
- `generated code matches spec` job verifies `src/generated` matches the committed spec;
220
- it cannot reach the private canonical swagger, so run `sync-spec` before cutting a
221
- release. (`npm test` stays hermetic.)
297
+ The release workflow runs the package checks and publishes prereleases to npm's `beta` tag.