@usewind/node 0.1.0

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/DESIGN.md ADDED
@@ -0,0 +1,404 @@
1
+ # `@usewind/node` — design
2
+
3
+ Server SDK for **Connect with Wind**. Wraps the OAuth (auth code + PKCE) connect
4
+ flow and the billed inference endpoint so an integration is *one middleware mount
5
+ plus one call*, instead of the ~120 lines in
6
+ `apps/docs/public/skills/wind-connect/SKILL.md`.
7
+
8
+ - **Contract:** `apps/docs/public/openapi.yaml` is the source of truth. Types in
9
+ this package are generated-by-hand from it and must track it.
10
+ - **Trust boundary:** this package holds the `client_secret` and the rotating
11
+ `refresh_token`. It is server-only and has no browser build. The button ships
12
+ separately as `@usewind/browser`.
13
+ - **Status:** implemented, tracking `apps/docs/public/openapi.yaml` (0.1.0).
14
+ Streaming (`chatStream`) is wired to the documented `Chattable` contract but
15
+ the gateway itself doesn't emit SSE yet (`apps/api` accepts `stream: true`
16
+ and returns one buffered JSON response) — `chatStream()` degrades to a
17
+ single terminal chunk until the server ships real streaming.
18
+
19
+ ---
20
+
21
+ ## Principles
22
+
23
+ 1. **Wrap, don't hide.** Every HTTP call the SDK makes is documented in
24
+ `rest-api.mdx`. `wind.raw` exposes the low-level client for anything the
25
+ ergonomic surface doesn't cover.
26
+ 2. **No datastore assumption, one persistence contract.** The app supplies a
27
+ `grantStore` — `{ get, set, delete }` keyed by the app's user id. That is the
28
+ *only* interface the SDK needs for correctness; it never writes to a DB
29
+ itself. `onConnected` / `onGrantRotated` are optional notifications layered on
30
+ top, not where persistence happens.
31
+ 3. **Rotation safety is the SDK's job.** `grantStore` is the only place refresh
32
+ tokens move. After a refresh the SDK calls `grantStore.set(userId, next)` and
33
+ **awaits it before** using the new access token — a throw aborts the call
34
+ rather than stranding a rotated token. From the caller's side it's one
35
+ `await`, new grant persisted before the billed call resumes.
36
+ 4. **Framework-agnostic core, thin adapters.** All logic works on a plain
37
+ `{ method, url, headers, body }` → `{ status, headers, body }` shape. Express
38
+ is the first adapter; Next.js route handlers and Fastify are adapters over
39
+ the same core.
40
+ 5. **Zero runtime dependencies.** Node 18+ `fetch`, `node:crypto`,
41
+ `URLSearchParams`. Nothing else.
42
+
43
+ ---
44
+
45
+ ## Package layout
46
+
47
+ ```
48
+ packages/node/
49
+ DESIGN.md this file
50
+ package.json @usewind/node — dual ESM/CJS, tsup
51
+ tsconfig.json extends ../../tsconfig.base.json
52
+ tsup.config.ts esm + cjs + .d.ts from src/index.ts
53
+ src/
54
+ index.ts public surface: `wind`, `createWind`, error classes, types
55
+ config.ts resolve + validate config (explicit args → env vars); memoryGrantStore()
56
+ errors.ts WindError hierarchy + fromResponse() factory
57
+ types.ts TokenResponse, WindBlock, Connection, Feature, ChatParams…
58
+ internal.ts b64url(), pkce(), basicAuth(), retryAfterMs(), sleep()
59
+ http.ts RawClient — one method per openapi path; retry/backoff
60
+ oauth.ts PKCE start + callback token exchange; framework core
61
+ tokens.ts access-token cache + atomic refresh-token rotation
62
+ client.ts wind.connection(userId).model(id)/.feature(slug).chat(…) billed calls
63
+ webhooks.ts wind.verify(req) — HMAC-SHA256 signature check
64
+ adapters/
65
+ express.ts wind.routes(...) → (req, res, next)
66
+ fetch.ts wind.handler(...) → (Request) => Response (Next.js, Hono)
67
+ test/
68
+ *.test.ts vitest — nock-free, fetch mocked
69
+ ```
70
+
71
+ ---
72
+
73
+ ## Public surface
74
+
75
+ ### Construction
76
+
77
+ ```ts
78
+ import { wind, createWind } from '@usewind/node'
79
+ ```
80
+
81
+ - **`wind`** — a lazily-configured default instance. On first use it reads
82
+ `WIND_CLIENT_ID`, `WIND_CLIENT_SECRET`, `WIND_REDIRECT_URI` from `process.env`.
83
+ Call `wind.configure({...})` once at boot to set them explicitly (wins over
84
+ env). One app, one instance — the common case.
85
+ - **`createWind(config)`** — an explicit, independent instance. For tests,
86
+ multi-tenant servers, or anything managing more than one Wind app.
87
+
88
+ ```ts
89
+ interface WindConfig {
90
+ clientId: string
91
+ clientSecret: string
92
+ redirectUri: string // must exactly match a registered URI + the callback route
93
+ baseUrl?: string // default 'https://api.usewind.app'
94
+ accountsUrl?: string // default 'https://accounts.usewind.app'
95
+ webhookSecret?: string // enables wind.verify()
96
+ fetch?: typeof fetch // injectable for tests
97
+ clock?: () => number // Date.now, injectable
98
+
99
+ // The one persistence contract. Required for wind.routes() / wind.handler() /
100
+ // wind.connection() — they throw WindConfigError without it. Not needed for
101
+ // client-credentials-only use (connections / account / raw).
102
+ grantStore?: GrantStore
103
+
104
+ // Optional notifications. NOT where persistence happens — they run after
105
+ // grantStore has committed, and a throw from either is caught and ignored.
106
+ onConnected?: (userId: string, grant: Grant) => Promise<void> | void // first connect only
107
+ onGrantRotated?: (userId: string, grant: Grant) => Promise<void> | void // every refresh
108
+ }
109
+
110
+ interface GrantStore {
111
+ get(userId: string): Promise<Grant | null> | Grant | null
112
+ set(userId: string, grant: Grant): Promise<void> | void // first connect AND every rotation; awaited before new tokens are used
113
+ delete(userId: string): Promise<void> | void // on revoke (terminal)
114
+ }
115
+
116
+ interface Grant {
117
+ connectionId: string // conn_…
118
+ refreshToken: string // rotates every refresh
119
+ accessToken?: string // cache; optional
120
+ accessTokenExpiresAt?: number // epoch ms
121
+ }
122
+ ```
123
+
124
+ Minimal in-memory `grantStore` (dev / tests) — a `Map` already satisfies the
125
+ shape:
126
+
127
+ ```ts
128
+ const grants = new Map<string, Grant>()
129
+ wind.configure({
130
+ grantStore: {
131
+ get: (id) => grants.get(id) ?? null,
132
+ set: (id, g) => { grants.set(id, g) },
133
+ delete: (id) => { grants.delete(id) },
134
+ },
135
+ })
136
+ ```
137
+
138
+ ### The connect routes
139
+
140
+ ```ts
141
+ // Express
142
+ app.use(wind.routes({
143
+ startPath: '/wind/start', // GET — build PKCE, redirect to accounts.usewind.app/oauth/authorize
144
+ callbackPath: '/wind/callback', // GET — verify state, POST /oauth/token, grantStore.set, onConnected
145
+ currentUser: (req) => req.session.userId, // required — Wind is not your login
146
+ // grantStore comes from config; pass grantStore here only to override it for these routes
147
+ session: 'cookie', // 'cookie' (signed, default) | custom { get, set }
148
+ onError: (err, req, res) => { … }, // default: 400 + short text, log
149
+ successRedirect: '/settings?wind=connected',
150
+ }))
151
+ ```
152
+
153
+ - **PKCE + `state`** are generated in `startPath` and stashed. Default storage is
154
+ a short-lived signed cookie (`__wind_tx`, 10 min, `HttpOnly`, `SameSite=Lax`);
155
+ pass `session` to use a server store instead.
156
+ - `startPath` forwards `requested_allocation_mc` and `features` if given as
157
+ options or query params.
158
+ - `callbackPath` verifies `state`, exchanges the `code` (HTTP Basic), builds the
159
+ `Grant`, `await`s `grantStore.set(userId, grant)`, then fires the optional
160
+ `onConnected` hook and redirects to `successRedirect`.
161
+
162
+ Framework-neutral form:
163
+
164
+ ```ts
165
+ const handler = wind.handler({ …same options… }) // (Request) => Promise<Response>
166
+ export const GET = handler // Next.js app/wind/[...wind]/route.ts
167
+ ```
168
+
169
+ ### Billed calls
170
+
171
+ `.model(id)` and `.feature(slug)` are **deliberately mutually exclusive, no
172
+ chaining** — each hits a different route with a different model-resolution
173
+ rule, and picking one is an explicit, unambiguous choice at the call site
174
+ rather than something that falls out of which optional field you happened to
175
+ pass.
176
+
177
+ ```ts
178
+ // model call — you pick the model. POST /v1/ai/{model}
179
+ const res = await wind.connection(userId).model('gpt-4o').chat({ messages, max_tokens: 500 })
180
+
181
+ // feature call — no model in the request. POST /v1/ai/features/{slug},
182
+ // resolved server-side from the feature's default_model (must be registered
183
+ // with one set, else 400 feature_model_not_configured)
184
+ const res = await wind.connection(userId).feature('voice_coach').chat({ messages })
185
+ ```
186
+
187
+ The one thing this can't express — a specific model *and* a feature tag on
188
+ the same call (`X-Wind-Feature` on `POST /v1/ai/{model}`, still a real,
189
+ supported combination on the wire) — drops to the escape hatch:
190
+ `wind.raw.inference({ model, feature, ... })`. Enforcing a simpler mental
191
+ model at the ergonomic layer doesn't cost anything at the raw layer.
192
+
193
+ `wind.connection(userId)` → `ConnectionScope`
194
+
195
+ | method | notes |
196
+ |---|---|
197
+ | `.model(id)` | returns a `ModelScope` — `.chat`/`.chatStream` hit `POST /v1/ai/{id}` |
198
+ | `.feature(slug)` | returns a `FeatureScope` — `.chat`/`.chatStream` hit `POST /v1/ai/features/{slug}`; slug validated against `^[a-z0-9-]{1,40}$` |
199
+ | `.get()` | `GET /v1/connections/{id}` for this user (named `.get()`, not `.connection()` — avoids `wind.connection(userId).connection()`) |
200
+ | `.revoke()` | `POST /v1/connections/{id}/revoke` |
201
+ | `.requestIncrease({ targetAllocationMc, reason })` | `POST …/allocation-requests` → `{ approvalUrl }` |
202
+
203
+ `ModelScope` and `FeatureScope` both implement the same `Chattable` interface
204
+ (`.chat` / `.chatStream`), and neither takes `model` in its params — it's
205
+ bound once by whichever scope you're in:
206
+
207
+ ```ts
208
+ interface ChatParams {
209
+ messages: ChatMessage[]
210
+ max_tokens?: number // optional — resolves request→feature→app→model ceiling
211
+ temperature?: number
212
+ stream?: boolean // prefer .chatStream()
213
+ idempotencyKey?: string // default: crypto.randomUUID(); reused on internal retry
214
+ signal?: AbortSignal
215
+ }
216
+
217
+ interface ChatResult {
218
+ id: string
219
+ choices: unknown[]
220
+ usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }
221
+ wind: WindBlock // hold_amount_mc, provider_cost_mc, wind_fee_mc, dev_margin_mc, user_charge_mc, allocation_remaining_mc, feature
222
+ }
223
+ ```
224
+
225
+ **What `.chat()` does internally** (identical for `ModelScope` and `FeatureScope`, only the route differs)
226
+
227
+ 1. `grantStore.get(userId)` → grant. No grant → `WindNotConnectedError`.
228
+ 2. Ensure a fresh access token: if `accessToken` missing or within 60 s of
229
+ expiry, `POST /oauth/token grant_type=refresh_token`. On success, build the
230
+ rotated grant, `await grantStore.set(userId, next)` **before proceeding**
231
+ (a throw here aborts the call), then fire `onGrantRotated` (errors ignored).
232
+ Refresh `400 invalid_grant` → `await grantStore.delete(userId)` and throw
233
+ `ConnectionRevokedError` (terminal).
234
+ 3. `POST /v1/ai/{model}` (from `ModelScope`) or `POST /v1/ai/features/{slug}`
235
+ (from `FeatureScope`) with `Authorization: Bearer`, `Idempotency-Key`.
236
+ 4. `401 token_expired` → refresh once (step 2), retry once with the **same**
237
+ idempotency key. Second 401 → `grantStore.delete(userId)` + `ConnectionRevokedError`.
238
+ 5. `502 provider_error` → retry up to 2× with the same idempotency key and
239
+ exponential backoff (250 ms, 1 s). Then throw `ProviderError`.
240
+ 6. `429` → throw `RateLimitedError` with `retryAfterMs` from `Retry-After`. The
241
+ SDK does **not** auto-sleep; the caller decides.
242
+ 7. `400 feature_model_not_configured` (`FeatureScope` only) → throw
243
+ `FeatureModelNotConfiguredError` — a config bug, not runtime-retryable.
244
+ 8. `403 connection_frozen` (Wind's platform risk layer, not user revocation) →
245
+ throw `ConnectionFrozenError`; unlike a revoke, the grant is NOT deleted —
246
+ the same connection reactivates once the user reconnects.
247
+ 9. Map any other non-2xx via `WindError.fromResponse`.
248
+
249
+ No fallback models, no non-cost-plus pricing (e.g. per-minute) — both deferred,
250
+ not part of this surface.
251
+
252
+ ### Management (client-credentials, no user context)
253
+
254
+ ```ts
255
+ wind.connections.list() // GET /v1/connections
256
+ wind.connections.get(connectionId)
257
+ wind.connections.revoke(connectionId)
258
+ wind.connections.requestIncrease(connectionId, { targetAllocationMc, reason })
259
+ wind.account() // GET /v1/account
260
+ wind.features.list(appId)
261
+ wind.features.register(appId, feature)
262
+ wind.features.update(appId, slug, patch)
263
+ ```
264
+
265
+ ### Webhooks
266
+
267
+ ```ts
268
+ const event = wind.verify(req) // throws WindSignatureError on mismatch
269
+ // event: { id: 'evt_…', type: 'connection.revoked' | 'allocation_request.approved' | …, data: {…} }
270
+ ```
271
+
272
+ - HMAC-SHA256 over the raw body with `webhookSecret`, constant-time compare,
273
+ `X-Wind-Signature: t=<unix>,v1=<hex>` header, 5-minute timestamp tolerance
274
+ (replay guard). Requires the raw (unparsed) body — documented loudly.
275
+
276
+ ### Escape hatch
277
+
278
+ ```ts
279
+ wind.raw.post('/v1/ai/gpt-4o', { headers, body }) // RawClient — typed per openapi path, no ergonomics
280
+ ```
281
+
282
+ ---
283
+
284
+ ## Error model (`src/errors.ts` — implemented now)
285
+
286
+ ```
287
+ WindError base; .status, .code, .type, .connectionId, .requestId, .raw
288
+ ├─ WindConfigError missing/invalid config; thrown at configure/first-use
289
+ ├─ WindNotConnectedError grantStore.get returned null for this user
290
+ ├─ WindApiError catch-all non-2xx not otherwise classified
291
+ │ ├─ AllocationExhaustedError 402 allocation_exhausted .increaseUrl
292
+ │ ├─ FeatureCostExceededError 402 feature_cost_exceeded (not billed)
293
+ │ ├─ FeatureModelNotConfiguredError 400 feature_model_not_configured (FeatureScope only, integration bug)
294
+ │ ├─ ConnectionRevokedError 403 connection_revoked / invalid_grant (terminal — clear the grant)
295
+ │ ├─ ConnectionFrozenError 403 connection_frozen (Wind's risk layer, NOT terminal — keep the grant, reactivates on reconnect)
296
+ │ ├─ AppSuspendedError 403 app_suspended (whole app off — stop calling, keep grants)
297
+ │ ├─ ModelNotAllowedError 403 model_not_allowed (integration bug)
298
+ │ ├─ RateLimitedError 429 rate_limited / daily_limit_reached / app_spend_limited / sandbox_live_cost_limited .retryAfterMs (.code says which)
299
+ │ ├─ RiskThrottledError 429 risk_throttled .retryAfterMs (Wind's risk layer, distinct from RateLimitedError)
300
+ │ ├─ ProviderError 502 provider_error (retried internally, not billed)
301
+ │ ├─ TokenExpiredError 401 token_expired (internal; surfaces only if refresh also fails)
302
+ │ └─ IdempotencyKeyReusedError 409 idempotency_key_reused (same key, different body — integration bug)
303
+ ├─ WindSignatureError webhook HMAC mismatch / stale timestamp
304
+ └─ WindNotImplementedError skeleton stub marker
305
+ ```
306
+
307
+ `WindError.fromResponse(status, bodyJson, { requestId })` reads the openapi
308
+ `Error` envelope (`error.code`, `error.type`, `error.connection_id`,
309
+ `error.increase_url`) and returns the right subclass. Codes are matched on
310
+ `error.code` first, then `status`.
311
+
312
+ ---
313
+
314
+ ## Decisions
315
+
316
+ - **Named `@usewind/node`, not `wind` or `wind-ai`.** Scope makes the
317
+ server/browser split legible; `wind-ai` was the retired product.
318
+ - **Two initialization styles, not one.** `import { wind } from '@usewind/node'`
319
+ is a lazily-configured singleton reading `WIND_CLIENT_*` from env — the
320
+ common case, one app, one instance, zero config drama. `new Wind(config)` (or
321
+ the equivalent `createWind(config)` factory) is an explicit, independent
322
+ instance for tests or multi-tenant servers. Both are cheap to keep; dropping
323
+ either loses either the "one script tag" pitch or testability.
324
+ - **`.connection(userId)`, not `.for(userId)`.** Renamed for domain alignment —
325
+ `Connection` is already the exact term used throughout the wire types
326
+ (`Connection`, `connection_id`, `ConnectionScope`); `.for()` was terser but
327
+ required knowing the convention rather than just reading the method name.
328
+ - **`.model(id)` / `.feature(slug)` are mutually exclusive, no chaining.**
329
+ Each hits a different route with a different model-resolution rule
330
+ (`POST /v1/ai/{model}` vs `POST /v1/ai/features/{slug}`), so picking one is
331
+ an explicit, unambiguous choice at the call site — not something that falls
332
+ out of whether an optional `model` field happened to be set (the earlier
333
+ design's `FeatureScope.chat({ model? })` silently switched routes on
334
+ presence/absence of that field, a real footgun). The one thing this can't
335
+ express — a specific model *and* a feature tag on one call — is still a
336
+ supported wire combination (`X-Wind-Feature` on `POST /v1/ai/{model}`); it's
337
+ reachable via `wind.raw.inference({ model, feature, ... })` instead. Keeping
338
+ the ergonomic surface unambiguous doesn't have to cost the escape hatch
339
+ anything.
340
+ - **Grant, not "tokens".** One object carries `connectionId` + `refreshToken`
341
+ (+ optional cached access token). The app persists and returns it whole; the
342
+ SDK never asks for fields piecemeal, which is how rotation bugs happen.
343
+ - **One `grantStore`, not three callbacks.** Persistence is correctness-critical
344
+ precisely *because* refresh tokens rotate, so it gets a single named contract
345
+ (`{ get, set, delete }`) with an explicit rotation rule — `set` is awaited
346
+ before the new tokens are used. The earlier `loadGrant` / `onConnected` /
347
+ `onGrantRotated` trio blurred "retrieve", "persist", and "notify" into
348
+ same-shaped functions; a missed `onGrantRotated` silently broke rotation.
349
+ - **`onConnected` / `onGrantRotated` are optional notifications**, layered over
350
+ `grantStore` — first-connect vs. every-refresh hooks for audit logs, emails,
351
+ analytics. They run *after* `grantStore` commits and their errors are caught
352
+ and ignored, so a broken hook can't strand a token.
353
+ - **No auto-sleep on 429.** A server SDK sleeping inside a request handler is a
354
+ footgun; hand back `retryAfterMs` and let the caller / queue decide.
355
+ - **Idempotency key is per logical `.chat()` call**, generated once, reused for
356
+ every internal retry of that call. Callers can pass their own for
357
+ cross-process dedupe.
358
+ - **Streaming is a separate method** (`.chatStream`), not a `stream: true` branch
359
+ that changes the return type.
360
+ - **Express adapter first**, `fetch`-handler second (covers Next.js / Hono /
361
+ Bun). Fastify plugin later if asked.
362
+ - **`peerDependencies`: none.** Express is detected structurally, not imported.
363
+
364
+ ## Open questions
365
+
366
+ - **Session storage default: RESOLVED — signed cookie.** PKCE transaction
367
+ state is write-once, ~10-minute, single-use — the risk profile is about
368
+ lifetime and mutability, not whether the blob happens to carry an
369
+ identifier (it may). Cookie (`__wind_tx`) must be `HttpOnly`, `Secure` in
370
+ production, `SameSite=Lax`, signed/authenticated, short-lived, and
371
+ size-bounded. `session: {get, set}` remains the override for apps with an
372
+ existing server-side session store.
373
+ - **Grant memoization: RESOLVED — no scope-level cache.** `wind.connection(userId)`
374
+ is a cheap, stateless factory; every `.chat()` does its own fresh
375
+ `grantStore.get()` at step 1. Caching on `ConnectionScope` was tempting
376
+ (avoid two reads for `.get()` then `.chat()`) but unsound: a scope isn't
377
+ guaranteed short-lived just because it isn't global — a caller can hold
378
+ `conn` across multiple `await`ed calls, and a concurrent refresh (another
379
+ request, or another `.chat()` on the same scope) can rotate the grant
380
+ underneath a stale cached copy, producing a silent stale-token retry
381
+ instead of a loud extra read. `grantStore.get()` is typically a cheap
382
+ local/Redis read anyway, dwarfed by the actual `POST /v1/ai/*` call — not
383
+ worth the correctness risk. If a real caller later shows `grantStore.get()`
384
+ is expensive, cache it *inside* a single `.chat()` invocation (fetched once
385
+ at entry, reused through its own internal retries), never on the scope
386
+ object.
387
+ - **`MapGrantStore` / `memoryGrantStore()` helper: RESOLVED — ship it.** Named
388
+ export, clearly labeled dev-only (name says "memory", JSDoc warns it
389
+ doesn't survive restarts / isn't safe for multi-instance prod) — people
390
+ will copy an unlabeled snippet to prod otherwise, so an officially-named,
391
+ documented-as-dev-only export is safer than not shipping one.
392
+ - **`grantStore.delete` on `403 connection_revoked`: RESOLVED — SDK deletes.**
393
+ A revoked grant is genuinely dead and can never be refreshed; leaving it
394
+ invites a retry loop that always 403s. Consistent with how refresh
395
+ `invalid_grant` is already handled (step 2 of `.chat()`).
396
+ - **`allocation_mc` on `Grant`: RESOLVED — keep credential-only.** `Grant`
397
+ staying single-purpose (credential + rotation) is why it's one object
398
+ instead of three callbacks; folding in dynamic state like allocation
399
+ reopens the same "piecemeal fields go stale" problem. Read allocation via
400
+ `.connection()` / `.get()` instead.
401
+ - **Retry budget / circuit-breaker config: RESOLVED — stay hard-coded.** No
402
+ config surface until a real caller needs one; matches the "wrap, don't
403
+ hide" posture and the already-deferred fallback-model / non-cost-plus
404
+ pricing scope.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # @usewind/node — the server SDK
2
+
3
+ Publishes as **`@usewind/node`**. Server-only — it handles the `client_secret`
4
+ and the rotating `refresh_token`, which must never reach a browser. The connect
5
+ button ships separately as `@usewind/browser`.
6
+
7
+ Status: **implemented**, tracking `apps/docs/public/openapi.yaml`. See
8
+ **[DESIGN.md](./DESIGN.md)** for the full API surface, module map, error
9
+ mapping, and design decisions.
10
+
11
+ Not a port. The retired `ai-wallet/packages/sdk-node` (`wind-ai`) targeted the
12
+ previous product (`sk_live_` keys, `windUserId` in the body, `/v1/ai/:featureId`).
13
+
14
+ ## Shape
15
+
16
+ ```js
17
+ import { wind } from '@usewind/node'
18
+
19
+ // 0. one persistence contract — { get, set, delete } keyed by your user id.
20
+ // `set` is called on first connect AND every refresh-token rotation, and is
21
+ // awaited before the new tokens are used. Back it with your database.
22
+ wind.configure({
23
+ grantStore: {
24
+ get: (userId) => db.users.getWindGrant(userId),
25
+ set: (userId, grant) => db.users.setWindGrant(userId, grant),
26
+ delete: (userId) => db.users.clearWindGrant(userId),
27
+ },
28
+ })
29
+
30
+ // 1. the connect routes (Express; wind.handler(...) for Next.js / fetch)
31
+ app.use(wind.routes({
32
+ currentUser: (req) => req.session.userId,
33
+ onConnected: (userId) => analytics.track(userId, 'wind_connected'), // optional hook, not persistence
34
+ }))
35
+
36
+ // 2. a billed call — refresh-and-retry, idempotency key, feature header handled inside
37
+ const res = await wind.connection(userId).model('gpt-4o').chat({
38
+ messages,
39
+ max_tokens: 500,
40
+ })
41
+ // res.wind.{ user_charge_mc, allocation_remaining_mc, ... }
42
+
43
+ // or, to bill against a registered feature's server-resolved default_model:
44
+ const res2 = await wind.connection(userId).feature('chat').chat({ messages })
45
+
46
+ // 3. webhooks
47
+ const event = wind.verify({ payload: rawBody, signature: req.headers['x-wind-signature'] })
48
+ ```
49
+
50
+ Credentials come from `wind.configure({ … })` or the env vars `WIND_CLIENT_ID`,
51
+ `WIND_CLIENT_SECRET`, `WIND_REDIRECT_URI`. `grantStore` is required for
52
+ `wind.routes()` / `wind.connection()`; without it those throw `WindConfigError`.
53
+ Use `createWind({ … })` for an explicit instance (tests, multi-tenant), or
54
+ `memoryGrantStore()` from this package for a dev-only in-memory `grantStore`.
55
+
56
+ ## Build
57
+
58
+ ```
59
+ pnpm --filter @usewind/node build # tsup → dist/ (esm + cjs + .d.ts)
60
+ pnpm --filter @usewind/node typecheck
61
+ pnpm --filter @usewind/node test
62
+ ```
63
+
64
+ Contract source of truth: `apps/docs/public/openapi.yaml`. The raw HTTP flow the
65
+ SDK wraps: `apps/docs/public/skills/wind-connect/SKILL.md`.