@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 +404 -0
- package/README.md +65 -0
- package/dist/index.cjs +1035 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +609 -0
- package/dist/index.d.ts +609 -0
- package/dist/index.js +1010 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
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`.
|