@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/dist/index.cjs
ADDED
|
@@ -0,0 +1,1035 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
// src/errors.ts
|
|
6
|
+
var WindError = class extends Error {
|
|
7
|
+
status;
|
|
8
|
+
code;
|
|
9
|
+
type;
|
|
10
|
+
connectionId;
|
|
11
|
+
requestId;
|
|
12
|
+
raw;
|
|
13
|
+
constructor(message, init = {}) {
|
|
14
|
+
super(message, init.cause !== void 0 ? { cause: init.cause } : void 0);
|
|
15
|
+
this.name = new.target.name;
|
|
16
|
+
this.status = init.status;
|
|
17
|
+
this.code = init.code;
|
|
18
|
+
this.type = init.type;
|
|
19
|
+
this.connectionId = init.connectionId ?? null;
|
|
20
|
+
this.requestId = init.requestId ?? null;
|
|
21
|
+
this.raw = init.raw;
|
|
22
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Build the right subclass from an HTTP response. Matches on the envelope's
|
|
26
|
+
* `error.code` first, then falls back to `status`.
|
|
27
|
+
*/
|
|
28
|
+
static fromResponse(status, body, extra = {}) {
|
|
29
|
+
const e = body?.error ?? {};
|
|
30
|
+
const message = e.message || `Wind API error (${status})`;
|
|
31
|
+
const init = {
|
|
32
|
+
status,
|
|
33
|
+
code: e.code,
|
|
34
|
+
type: e.type,
|
|
35
|
+
connectionId: e.connection_id ?? null,
|
|
36
|
+
requestId: extra.requestId ?? null,
|
|
37
|
+
raw: body
|
|
38
|
+
};
|
|
39
|
+
switch (e.code) {
|
|
40
|
+
case "allocation_exhausted":
|
|
41
|
+
return new AllocationExhaustedError(message, { ...init, increaseUrl: e.increase_url ?? null });
|
|
42
|
+
case "feature_cost_exceeded":
|
|
43
|
+
return new FeatureCostExceededError(message, init);
|
|
44
|
+
case "feature_model_not_configured":
|
|
45
|
+
return new FeatureModelNotConfiguredError(message, init);
|
|
46
|
+
case "connection_revoked":
|
|
47
|
+
case "invalid_grant":
|
|
48
|
+
return new ConnectionRevokedError(message, init);
|
|
49
|
+
case "connection_frozen":
|
|
50
|
+
return new ConnectionFrozenError(message, init);
|
|
51
|
+
case "model_not_allowed":
|
|
52
|
+
return new ModelNotAllowedError(message, init);
|
|
53
|
+
case "app_suspended":
|
|
54
|
+
return new AppSuspendedError(message, init);
|
|
55
|
+
case "rate_limited":
|
|
56
|
+
case "daily_limit_reached":
|
|
57
|
+
case "app_spend_limited":
|
|
58
|
+
case "sandbox_live_cost_limited":
|
|
59
|
+
return new RateLimitedError(message, { ...init, retryAfterMs: extra.retryAfterMs });
|
|
60
|
+
case "risk_throttled":
|
|
61
|
+
return new RiskThrottledError(message, { ...init, retryAfterMs: extra.retryAfterMs });
|
|
62
|
+
case "provider_error":
|
|
63
|
+
return new ProviderError(message, init);
|
|
64
|
+
case "token_expired":
|
|
65
|
+
return new TokenExpiredError(message, init);
|
|
66
|
+
case "idempotency_key_reused":
|
|
67
|
+
return new IdempotencyKeyReusedError(message, init);
|
|
68
|
+
}
|
|
69
|
+
switch (status) {
|
|
70
|
+
case 401:
|
|
71
|
+
return new TokenExpiredError(message, init);
|
|
72
|
+
case 402:
|
|
73
|
+
return new AllocationExhaustedError(message, init);
|
|
74
|
+
case 403:
|
|
75
|
+
return new ConnectionRevokedError(message, init);
|
|
76
|
+
case 409:
|
|
77
|
+
return new IdempotencyKeyReusedError(message, init);
|
|
78
|
+
case 429:
|
|
79
|
+
return new RateLimitedError(message, { ...init, retryAfterMs: extra.retryAfterMs });
|
|
80
|
+
case 502:
|
|
81
|
+
return new ProviderError(message, init);
|
|
82
|
+
default:
|
|
83
|
+
return new WindApiError(message, init);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
var WindConfigError = class extends WindError {
|
|
88
|
+
};
|
|
89
|
+
var WindNotConnectedError = class extends WindError {
|
|
90
|
+
};
|
|
91
|
+
var WindSignatureError = class extends WindError {
|
|
92
|
+
};
|
|
93
|
+
var WindNotImplementedError = class extends WindError {
|
|
94
|
+
constructor(what) {
|
|
95
|
+
super(`${what} is not implemented yet (@usewind/node is a skeleton).`);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
var WindApiError = class extends WindError {
|
|
99
|
+
};
|
|
100
|
+
var AllocationExhaustedError = class extends WindApiError {
|
|
101
|
+
increaseUrl;
|
|
102
|
+
constructor(message, init = {}) {
|
|
103
|
+
super(message, init);
|
|
104
|
+
this.increaseUrl = init.increaseUrl ?? null;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
var FeatureCostExceededError = class extends WindApiError {
|
|
108
|
+
};
|
|
109
|
+
var FeatureModelNotConfiguredError = class extends WindApiError {
|
|
110
|
+
};
|
|
111
|
+
var ConnectionRevokedError = class extends WindApiError {
|
|
112
|
+
};
|
|
113
|
+
var ConnectionFrozenError = class extends WindApiError {
|
|
114
|
+
};
|
|
115
|
+
var AppSuspendedError = class extends WindApiError {
|
|
116
|
+
};
|
|
117
|
+
var ModelNotAllowedError = class extends WindApiError {
|
|
118
|
+
};
|
|
119
|
+
var RateLimitedError = class extends WindApiError {
|
|
120
|
+
retryAfterMs;
|
|
121
|
+
constructor(message, init = {}) {
|
|
122
|
+
super(message, init);
|
|
123
|
+
this.retryAfterMs = init.retryAfterMs ?? null;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
var RiskThrottledError = class extends WindApiError {
|
|
127
|
+
retryAfterMs;
|
|
128
|
+
constructor(message, init = {}) {
|
|
129
|
+
super(message, init);
|
|
130
|
+
this.retryAfterMs = init.retryAfterMs ?? null;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
var ProviderError = class extends WindApiError {
|
|
134
|
+
};
|
|
135
|
+
var TokenExpiredError = class extends WindApiError {
|
|
136
|
+
};
|
|
137
|
+
var IdempotencyKeyReusedError = class extends WindApiError {
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// src/config.ts
|
|
141
|
+
function memoryGrantStore() {
|
|
142
|
+
const grants = /* @__PURE__ */ new Map();
|
|
143
|
+
return {
|
|
144
|
+
get: (userId) => grants.get(userId) ?? null,
|
|
145
|
+
set: (userId, grant) => {
|
|
146
|
+
grants.set(userId, grant);
|
|
147
|
+
},
|
|
148
|
+
delete: (userId) => {
|
|
149
|
+
grants.delete(userId);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
var DEFAULT_BASE_URL = "https://api.usewind.app";
|
|
154
|
+
var DEFAULT_ACCOUNTS_URL = "https://accounts.usewind.app";
|
|
155
|
+
function resolveConfig(partial = {}) {
|
|
156
|
+
const env = typeof process !== "undefined" ? process.env : {};
|
|
157
|
+
const clientId = partial.clientId ?? env.WIND_CLIENT_ID;
|
|
158
|
+
const clientSecret = partial.clientSecret ?? env.WIND_CLIENT_SECRET;
|
|
159
|
+
const redirectUri = partial.redirectUri ?? env.WIND_REDIRECT_URI;
|
|
160
|
+
const missing = [
|
|
161
|
+
!clientId && "clientId (WIND_CLIENT_ID)",
|
|
162
|
+
!clientSecret && "clientSecret (WIND_CLIENT_SECRET)",
|
|
163
|
+
!redirectUri && "redirectUri (WIND_REDIRECT_URI)"
|
|
164
|
+
].filter(Boolean);
|
|
165
|
+
if (missing.length) {
|
|
166
|
+
throw new WindConfigError(
|
|
167
|
+
`@usewind/node is not configured. Missing: ${missing.join(", ")}. Call wind.configure({ \u2026 }) at boot or set the environment variables.`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const fetchImpl = partial.fetch ?? globalThis.fetch;
|
|
171
|
+
if (typeof fetchImpl !== "function") {
|
|
172
|
+
throw new WindConfigError(
|
|
173
|
+
"No fetch implementation available. Use Node 18+ or pass config.fetch."
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
clientId,
|
|
178
|
+
clientSecret,
|
|
179
|
+
redirectUri,
|
|
180
|
+
baseUrl: (partial.baseUrl ?? env.WIND_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/$/, ""),
|
|
181
|
+
accountsUrl: (partial.accountsUrl ?? env.WIND_ACCOUNTS_URL ?? DEFAULT_ACCOUNTS_URL).replace(/\/$/, ""),
|
|
182
|
+
fetch: fetchImpl,
|
|
183
|
+
clock: partial.clock ?? Date.now,
|
|
184
|
+
webhookSecret: partial.webhookSecret ?? env.WIND_WEBHOOK_SECRET,
|
|
185
|
+
grantStore: partial.grantStore,
|
|
186
|
+
onConnected: partial.onConnected,
|
|
187
|
+
onGrantRotated: partial.onGrantRotated
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function b64url(input) {
|
|
191
|
+
return Buffer.from(input).toString("base64url");
|
|
192
|
+
}
|
|
193
|
+
function pkce() {
|
|
194
|
+
const verifier = b64url(crypto.randomBytes(48));
|
|
195
|
+
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
|
|
196
|
+
return { verifier, challenge, method: "S256" };
|
|
197
|
+
}
|
|
198
|
+
function stateToken() {
|
|
199
|
+
return b64url(crypto.randomBytes(24));
|
|
200
|
+
}
|
|
201
|
+
function basicAuth(clientId, clientSecret) {
|
|
202
|
+
return "Basic " + Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
|
|
203
|
+
}
|
|
204
|
+
function retryAfterMs(header, now = Date.now()) {
|
|
205
|
+
if (!header) return void 0;
|
|
206
|
+
const secs = Number(header);
|
|
207
|
+
if (Number.isFinite(secs)) return Math.max(0, secs * 1e3);
|
|
208
|
+
const date = Date.parse(header);
|
|
209
|
+
return Number.isFinite(date) ? Math.max(0, date - now) : void 0;
|
|
210
|
+
}
|
|
211
|
+
function sleep(ms) {
|
|
212
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/http.ts
|
|
216
|
+
var RawClient = class {
|
|
217
|
+
constructor(config) {
|
|
218
|
+
this.config = config;
|
|
219
|
+
}
|
|
220
|
+
config;
|
|
221
|
+
/** Low-level: perform one request, parse JSON, never throw on non-2xx (returns the response). */
|
|
222
|
+
async request(req) {
|
|
223
|
+
const url = `${this.config.baseUrl}${req.path}`;
|
|
224
|
+
const headers = { Accept: "application/json", ...req.headers };
|
|
225
|
+
if (req.bearer) headers.Authorization = `Bearer ${req.bearer}`;
|
|
226
|
+
else if (req.basic) headers.Authorization = basicAuth(this.config.clientId, this.config.clientSecret);
|
|
227
|
+
let body;
|
|
228
|
+
if (req.body !== void 0) {
|
|
229
|
+
headers["Content-Type"] = "application/json";
|
|
230
|
+
body = JSON.stringify(req.body);
|
|
231
|
+
}
|
|
232
|
+
const res = await this.config.fetch(url, {
|
|
233
|
+
method: req.method,
|
|
234
|
+
headers,
|
|
235
|
+
body,
|
|
236
|
+
signal: req.signal
|
|
237
|
+
});
|
|
238
|
+
const requestId = res.headers.get("x-request-id");
|
|
239
|
+
let parsed = void 0;
|
|
240
|
+
const text = await res.text();
|
|
241
|
+
if (text) {
|
|
242
|
+
try {
|
|
243
|
+
parsed = JSON.parse(text);
|
|
244
|
+
} catch {
|
|
245
|
+
parsed = text;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { status: res.status, headers: res.headers, body: parsed, requestId };
|
|
249
|
+
}
|
|
250
|
+
// ── OAuth ──────────────────────────────────────────────────────────────────
|
|
251
|
+
/** `POST /oauth/token` — `grant_type=authorization_code`. */
|
|
252
|
+
async exchangeCode(args) {
|
|
253
|
+
return this.postToken({
|
|
254
|
+
grant_type: "authorization_code",
|
|
255
|
+
code: args.code,
|
|
256
|
+
redirect_uri: args.redirectUri,
|
|
257
|
+
code_verifier: args.codeVerifier
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
/** `POST /oauth/token` — `grant_type=refresh_token`. */
|
|
261
|
+
async refresh(refreshToken) {
|
|
262
|
+
return this.postToken({ grant_type: "refresh_token", refresh_token: refreshToken });
|
|
263
|
+
}
|
|
264
|
+
async postToken(form) {
|
|
265
|
+
const url = `${this.config.baseUrl}/oauth/token`;
|
|
266
|
+
const res = await this.config.fetch(url, {
|
|
267
|
+
method: "POST",
|
|
268
|
+
headers: {
|
|
269
|
+
Accept: "application/json",
|
|
270
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
271
|
+
Authorization: basicAuth(this.config.clientId, this.config.clientSecret)
|
|
272
|
+
},
|
|
273
|
+
body: new URLSearchParams(form).toString()
|
|
274
|
+
});
|
|
275
|
+
const text = await res.text();
|
|
276
|
+
const parsed = text ? JSON.parse(text) : void 0;
|
|
277
|
+
if (!res.ok) {
|
|
278
|
+
throw WindError.fromResponse(res.status, parsed, { requestId: res.headers.get("x-request-id") });
|
|
279
|
+
}
|
|
280
|
+
return parsed;
|
|
281
|
+
}
|
|
282
|
+
// ── Inference ──────────────────────────────────────────────────────────────
|
|
283
|
+
/** `POST /v1/ai/{model}`. Returns the raw response so callers can branch on status/headers. */
|
|
284
|
+
inference(args) {
|
|
285
|
+
const headers = { "Idempotency-Key": args.idempotencyKey };
|
|
286
|
+
if (args.feature) headers["X-Wind-Feature"] = args.feature;
|
|
287
|
+
return this.request({
|
|
288
|
+
method: "POST",
|
|
289
|
+
path: `/v1/ai/${encodeURIComponent(args.model)}`,
|
|
290
|
+
bearer: args.accessToken,
|
|
291
|
+
headers,
|
|
292
|
+
body: args.body,
|
|
293
|
+
signal: args.signal
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
/** `POST /v1/ai/features/{slug}`. */
|
|
297
|
+
inferenceFeature(args) {
|
|
298
|
+
return this.request({
|
|
299
|
+
method: "POST",
|
|
300
|
+
path: `/v1/ai/features/${encodeURIComponent(args.slug)}`,
|
|
301
|
+
bearer: args.accessToken,
|
|
302
|
+
headers: { "Idempotency-Key": args.idempotencyKey },
|
|
303
|
+
body: args.body,
|
|
304
|
+
signal: args.signal
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
// ── Management (client-credentials) ────────────────────────────────────────
|
|
308
|
+
async listConnections() {
|
|
309
|
+
const res = await this.request({ method: "GET", path: "/v1/connections", basic: true });
|
|
310
|
+
return this.unwrap(res);
|
|
311
|
+
}
|
|
312
|
+
async getConnection(id) {
|
|
313
|
+
const res = await this.request({ method: "GET", path: `/v1/connections/${encodeURIComponent(id)}`, basic: true });
|
|
314
|
+
return this.unwrap(res);
|
|
315
|
+
}
|
|
316
|
+
async revokeConnection(id) {
|
|
317
|
+
const res = await this.request({ method: "POST", path: `/v1/connections/${encodeURIComponent(id)}/revoke`, basic: true });
|
|
318
|
+
return this.unwrap(res);
|
|
319
|
+
}
|
|
320
|
+
async createAllocationRequest(id, body) {
|
|
321
|
+
const res = await this.request({
|
|
322
|
+
method: "POST",
|
|
323
|
+
path: `/v1/connections/${encodeURIComponent(id)}/allocation-requests`,
|
|
324
|
+
basic: true,
|
|
325
|
+
body
|
|
326
|
+
});
|
|
327
|
+
return this.unwrap(res);
|
|
328
|
+
}
|
|
329
|
+
async getAccount() {
|
|
330
|
+
const res = await this.request({ method: "GET", path: "/v1/account", basic: true });
|
|
331
|
+
return this.unwrap(res);
|
|
332
|
+
}
|
|
333
|
+
async listFeatures(appId) {
|
|
334
|
+
const res = await this.request({ method: "GET", path: `/v1/apps/${encodeURIComponent(appId)}/features`, basic: true });
|
|
335
|
+
return this.unwrap(res);
|
|
336
|
+
}
|
|
337
|
+
async registerFeature(appId, feature) {
|
|
338
|
+
const res = await this.request({
|
|
339
|
+
method: "POST",
|
|
340
|
+
path: `/v1/apps/${encodeURIComponent(appId)}/features`,
|
|
341
|
+
basic: true,
|
|
342
|
+
body: feature
|
|
343
|
+
});
|
|
344
|
+
return this.unwrap(res);
|
|
345
|
+
}
|
|
346
|
+
async updateFeature(appId, slug, patch) {
|
|
347
|
+
const res = await this.request({
|
|
348
|
+
method: "PATCH",
|
|
349
|
+
path: `/v1/apps/${encodeURIComponent(appId)}/features/${encodeURIComponent(slug)}`,
|
|
350
|
+
basic: true,
|
|
351
|
+
body: patch
|
|
352
|
+
});
|
|
353
|
+
return this.unwrap(res);
|
|
354
|
+
}
|
|
355
|
+
unwrap(res) {
|
|
356
|
+
if (res.status >= 200 && res.status < 300) return res.body;
|
|
357
|
+
throw WindError.fromResponse(res.status, res.body, { requestId: res.requestId });
|
|
358
|
+
}
|
|
359
|
+
// Convenience verbs for `wind.raw.get(...)` / `.post(...)`.
|
|
360
|
+
get(path, init) {
|
|
361
|
+
return this.request({ method: "GET", path, ...init });
|
|
362
|
+
}
|
|
363
|
+
post(path, init) {
|
|
364
|
+
return this.request({ method: "POST", path, ...init });
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
// src/tokens.ts
|
|
369
|
+
var ACCESS_TOKEN_SKEW_MS = 6e4;
|
|
370
|
+
var TokenManager = class {
|
|
371
|
+
constructor(config, raw) {
|
|
372
|
+
this.config = config;
|
|
373
|
+
this.raw = raw;
|
|
374
|
+
}
|
|
375
|
+
config;
|
|
376
|
+
raw;
|
|
377
|
+
/** True if the grant's cached access token is present and outside the skew window. */
|
|
378
|
+
isFresh(grant) {
|
|
379
|
+
if (!grant.accessToken || grant.accessTokenExpiresAt === void 0) return false;
|
|
380
|
+
return grant.accessTokenExpiresAt - ACCESS_TOKEN_SKEW_MS > this.config.clock();
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Return a grant guaranteed to have a usable access token. If a refresh was
|
|
384
|
+
* needed, `grantStore.set(userId, next)` has already been awaited before this
|
|
385
|
+
* resolves (then `onGrantRotated` fired). Refresh failure (`invalid_grant`)
|
|
386
|
+
* throws `ConnectionRevokedError` — the caller then clears the store entry.
|
|
387
|
+
*/
|
|
388
|
+
async ensureAccessToken(userId, grant) {
|
|
389
|
+
if (this.isFresh(grant)) return grant;
|
|
390
|
+
return this.forceRefresh(userId, grant);
|
|
391
|
+
}
|
|
392
|
+
/** Force a refresh regardless of cache state (used on a 401 retry). */
|
|
393
|
+
async forceRefresh(userId, grant) {
|
|
394
|
+
const grantStore = this.config.grantStore;
|
|
395
|
+
if (!grantStore) {
|
|
396
|
+
throw new WindConfigError("grantStore is required to refresh a connection but was not configured.");
|
|
397
|
+
}
|
|
398
|
+
let token;
|
|
399
|
+
try {
|
|
400
|
+
token = await this.raw.refresh(grant.refreshToken);
|
|
401
|
+
} catch (err) {
|
|
402
|
+
if (err instanceof ConnectionRevokedError) {
|
|
403
|
+
await grantStore.delete(userId);
|
|
404
|
+
}
|
|
405
|
+
throw err;
|
|
406
|
+
}
|
|
407
|
+
const next = grantFromToken(token, this.config.clock());
|
|
408
|
+
await grantStore.set(userId, next);
|
|
409
|
+
if (this.config.onGrantRotated) {
|
|
410
|
+
try {
|
|
411
|
+
await this.config.onGrantRotated(userId, next);
|
|
412
|
+
} catch {
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return next;
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
function grantFromToken(token, now) {
|
|
419
|
+
return {
|
|
420
|
+
connectionId: token.connection_id,
|
|
421
|
+
refreshToken: token.refresh_token,
|
|
422
|
+
accessToken: token.access_token,
|
|
423
|
+
accessTokenExpiresAt: now + token.expires_in * 1e3
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// src/oauth.ts
|
|
428
|
+
var DEFAULT_START_PATH = "/wind/start";
|
|
429
|
+
var DEFAULT_CALLBACK_PATH = "/wind/callback";
|
|
430
|
+
var TRANSACTION_TTL_MS = 10 * 6e4;
|
|
431
|
+
var TX_COOKIE_NAME = "__wind_tx";
|
|
432
|
+
var TX_ID_COOKIE_NAME = "__wind_tx_id";
|
|
433
|
+
function signTransactionCookie(tx, clientSecret) {
|
|
434
|
+
const payload = b64url(JSON.stringify(tx));
|
|
435
|
+
const sig = crypto.createHmac("sha256", clientSecret).update(payload).digest("hex");
|
|
436
|
+
return `${payload}.${sig}`;
|
|
437
|
+
}
|
|
438
|
+
function verifyTransactionCookie(value, clientSecret) {
|
|
439
|
+
if (!value) return null;
|
|
440
|
+
const dot = value.lastIndexOf(".");
|
|
441
|
+
if (dot < 0) return null;
|
|
442
|
+
const payload = value.slice(0, dot);
|
|
443
|
+
const providedHex = value.slice(dot + 1);
|
|
444
|
+
const expectedHex = crypto.createHmac("sha256", clientSecret).update(payload).digest("hex");
|
|
445
|
+
const provided = Buffer.from(providedHex, "hex");
|
|
446
|
+
const expected = Buffer.from(expectedHex, "hex");
|
|
447
|
+
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) return null;
|
|
448
|
+
try {
|
|
449
|
+
const json = Buffer.from(payload, "base64url").toString("utf8");
|
|
450
|
+
const tx = JSON.parse(json);
|
|
451
|
+
if (typeof tx?.verifier !== "string" || typeof tx?.state !== "string" || typeof tx?.createdAt !== "number") return null;
|
|
452
|
+
return tx;
|
|
453
|
+
} catch {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
var OAuthFlow = class {
|
|
458
|
+
constructor(config, raw) {
|
|
459
|
+
this.config = config;
|
|
460
|
+
this.raw = raw;
|
|
461
|
+
}
|
|
462
|
+
config;
|
|
463
|
+
raw;
|
|
464
|
+
/**
|
|
465
|
+
* Build the `accounts.usewind.app/oauth/authorize` URL and the transaction to
|
|
466
|
+
* stash. The adapter persists `tx` and issues the redirect.
|
|
467
|
+
*/
|
|
468
|
+
buildAuthorizeRedirect(opts) {
|
|
469
|
+
const { verifier, challenge, method } = pkce();
|
|
470
|
+
const state = stateToken();
|
|
471
|
+
const url = new URL("/oauth/authorize", this.config.accountsUrl);
|
|
472
|
+
url.searchParams.set("response_type", "code");
|
|
473
|
+
url.searchParams.set("client_id", this.config.clientId);
|
|
474
|
+
url.searchParams.set("redirect_uri", this.config.redirectUri);
|
|
475
|
+
url.searchParams.set("code_challenge", challenge);
|
|
476
|
+
url.searchParams.set("code_challenge_method", method);
|
|
477
|
+
url.searchParams.set("state", state);
|
|
478
|
+
if (opts.requestedAllocationMc !== void 0) {
|
|
479
|
+
url.searchParams.set("requested_allocation_mc", String(opts.requestedAllocationMc));
|
|
480
|
+
}
|
|
481
|
+
if (opts.features?.length) {
|
|
482
|
+
url.searchParams.set("features", opts.features.join(","));
|
|
483
|
+
}
|
|
484
|
+
return { url: url.toString(), tx: { verifier, state, createdAt: this.config.clock() } };
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Given the callback query and the stashed transaction, verify `state`,
|
|
488
|
+
* exchange the `code`, and return the fresh `Grant`. Does not persist — the
|
|
489
|
+
* adapter writes it via `grantStore.set`, then fires the `onConnected` hook.
|
|
490
|
+
*/
|
|
491
|
+
async completeCallback(args) {
|
|
492
|
+
const { query, tx } = args;
|
|
493
|
+
if (query.error) {
|
|
494
|
+
throw new WindConfigError(`Wind connect flow was not completed: ${query.error}`);
|
|
495
|
+
}
|
|
496
|
+
if (!tx) {
|
|
497
|
+
throw new WindConfigError("No pending Wind connect transaction found \u2014 it may have expired (10 min TTL) or the cookie was lost.");
|
|
498
|
+
}
|
|
499
|
+
if (this.config.clock() - tx.createdAt > TRANSACTION_TTL_MS) {
|
|
500
|
+
throw new WindConfigError("Wind connect transaction expired. Start the connection again.");
|
|
501
|
+
}
|
|
502
|
+
if (!query.state || query.state !== tx.state) {
|
|
503
|
+
throw new WindConfigError("state mismatch on Wind connect callback \u2014 possible CSRF, aborting.");
|
|
504
|
+
}
|
|
505
|
+
if (!query.code) {
|
|
506
|
+
throw new WindConfigError("Missing code on Wind connect callback.");
|
|
507
|
+
}
|
|
508
|
+
const token = await this.raw.exchangeCode({
|
|
509
|
+
code: query.code,
|
|
510
|
+
redirectUri: this.config.redirectUri,
|
|
511
|
+
codeVerifier: tx.verifier
|
|
512
|
+
});
|
|
513
|
+
return grantFromToken(token, this.config.clock());
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
var FEATURE_SLUG_RE = /^[a-z0-9-]{1,40}$/;
|
|
517
|
+
var PROVIDER_ERROR_RETRIES = 2;
|
|
518
|
+
var PROVIDER_ERROR_BACKOFF_MS = [250, 1e3];
|
|
519
|
+
var ModelScope = class {
|
|
520
|
+
constructor(ctx, userId, model) {
|
|
521
|
+
this.ctx = ctx;
|
|
522
|
+
this.userId = userId;
|
|
523
|
+
this.model = model;
|
|
524
|
+
}
|
|
525
|
+
ctx;
|
|
526
|
+
userId;
|
|
527
|
+
model;
|
|
528
|
+
chat(params) {
|
|
529
|
+
return billedCall(
|
|
530
|
+
this.ctx,
|
|
531
|
+
this.userId,
|
|
532
|
+
params,
|
|
533
|
+
(accessToken, idempotencyKey, body, signal) => this.ctx.raw.inference({ model: this.model, accessToken, idempotencyKey, body, signal })
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
chatStream(params) {
|
|
537
|
+
return billedStream(
|
|
538
|
+
this.ctx,
|
|
539
|
+
this.userId,
|
|
540
|
+
params,
|
|
541
|
+
(accessToken, idempotencyKey, body, signal) => this.ctx.raw.inference({ model: this.model, accessToken, idempotencyKey, body, signal })
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
var FeatureScope = class {
|
|
546
|
+
constructor(ctx, userId, feature) {
|
|
547
|
+
this.ctx = ctx;
|
|
548
|
+
this.userId = userId;
|
|
549
|
+
this.feature = feature;
|
|
550
|
+
if (!FEATURE_SLUG_RE.test(feature)) {
|
|
551
|
+
throw new TypeError(`Invalid feature slug ${JSON.stringify(feature)} \u2014 must match ${FEATURE_SLUG_RE}`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
ctx;
|
|
555
|
+
userId;
|
|
556
|
+
feature;
|
|
557
|
+
chat(params) {
|
|
558
|
+
return billedCall(
|
|
559
|
+
this.ctx,
|
|
560
|
+
this.userId,
|
|
561
|
+
params,
|
|
562
|
+
(accessToken, idempotencyKey, body, signal) => this.ctx.raw.inferenceFeature({ slug: this.feature, accessToken, idempotencyKey, body, signal })
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
chatStream(params) {
|
|
566
|
+
return billedStream(
|
|
567
|
+
this.ctx,
|
|
568
|
+
this.userId,
|
|
569
|
+
params,
|
|
570
|
+
(accessToken, idempotencyKey, body, signal) => this.ctx.raw.inferenceFeature({ slug: this.feature, accessToken, idempotencyKey, body, signal })
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
var ConnectionScope = class {
|
|
575
|
+
constructor(ctx, userId) {
|
|
576
|
+
this.ctx = ctx;
|
|
577
|
+
this.userId = userId;
|
|
578
|
+
}
|
|
579
|
+
ctx;
|
|
580
|
+
userId;
|
|
581
|
+
/** `wind.connection(userId).model('gpt-5').chat(...)` — hits `POST /v1/ai/{model}`. */
|
|
582
|
+
model(id) {
|
|
583
|
+
return new ModelScope(this.ctx, this.userId, id);
|
|
584
|
+
}
|
|
585
|
+
/** `wind.connection(userId).feature('summarize').chat(...)` — hits `POST /v1/ai/features/{slug}`. */
|
|
586
|
+
feature(slug) {
|
|
587
|
+
return new FeatureScope(this.ctx, this.userId, slug);
|
|
588
|
+
}
|
|
589
|
+
/** `GET /v1/connections/{id}` for this user's connection. Named `.get()`, not `.connection()` — avoids `wind.connection(userId).connection()`. */
|
|
590
|
+
async get() {
|
|
591
|
+
const grant = await requireGrant(this.ctx, this.userId);
|
|
592
|
+
return this.ctx.raw.getConnection(grant.connectionId);
|
|
593
|
+
}
|
|
594
|
+
/** `POST /v1/connections/{id}/revoke`. */
|
|
595
|
+
async revoke() {
|
|
596
|
+
const grant = await requireGrant(this.ctx, this.userId);
|
|
597
|
+
const result = await this.ctx.raw.revokeConnection(grant.connectionId);
|
|
598
|
+
await this.ctx.config.grantStore?.delete(this.userId);
|
|
599
|
+
return result;
|
|
600
|
+
}
|
|
601
|
+
/** `POST /v1/connections/{id}/allocation-requests` — returns `{ approvalUrl }`. */
|
|
602
|
+
async requestIncrease(params) {
|
|
603
|
+
const grant = await requireGrant(this.ctx, this.userId);
|
|
604
|
+
return this.ctx.raw.createAllocationRequest(grant.connectionId, {
|
|
605
|
+
target_allocation_mc: params.targetAllocationMc,
|
|
606
|
+
reason: params.reason
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
async function requireGrant(ctx, userId) {
|
|
611
|
+
if (!ctx.config.grantStore) {
|
|
612
|
+
throw new WindNotConnectedError("grantStore is required for wind.connection(userId) but was not configured.");
|
|
613
|
+
}
|
|
614
|
+
const grant = await ctx.config.grantStore.get(userId);
|
|
615
|
+
if (!grant) {
|
|
616
|
+
throw new WindNotConnectedError(`No Wind connection found for user ${JSON.stringify(userId)}.`);
|
|
617
|
+
}
|
|
618
|
+
return grant;
|
|
619
|
+
}
|
|
620
|
+
function requestBody(params) {
|
|
621
|
+
return {
|
|
622
|
+
messages: params.messages,
|
|
623
|
+
...params.max_tokens !== void 0 && { max_tokens: params.max_tokens },
|
|
624
|
+
...params.temperature !== void 0 && { temperature: params.temperature },
|
|
625
|
+
...params.stream !== void 0 && { stream: params.stream }
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
async function billedCall(ctx, userId, params, call) {
|
|
629
|
+
let grant = await requireGrant(ctx, userId);
|
|
630
|
+
grant = await ctx.tokens.ensureAccessToken(userId, grant);
|
|
631
|
+
const idempotencyKey = params.idempotencyKey ?? crypto.randomUUID();
|
|
632
|
+
const body = requestBody(params);
|
|
633
|
+
let res = await call(grant.accessToken, idempotencyKey, body, params.signal);
|
|
634
|
+
if (res.status === 401) {
|
|
635
|
+
grant = await ctx.tokens.forceRefresh(userId, grant);
|
|
636
|
+
res = await call(grant.accessToken, idempotencyKey, body, params.signal);
|
|
637
|
+
if (res.status === 401) {
|
|
638
|
+
await ctx.config.grantStore?.delete(userId);
|
|
639
|
+
throw WindError.fromResponse(res.status, res.body, { requestId: res.requestId });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
for (let attempt = 0; res.status === 502 && attempt < PROVIDER_ERROR_RETRIES; attempt++) {
|
|
643
|
+
await sleep(PROVIDER_ERROR_BACKOFF_MS[attempt]);
|
|
644
|
+
res = await call(grant.accessToken, idempotencyKey, body, params.signal);
|
|
645
|
+
}
|
|
646
|
+
if (res.status < 200 || res.status >= 300) {
|
|
647
|
+
throw WindError.fromResponse(res.status, res.body, {
|
|
648
|
+
requestId: res.requestId,
|
|
649
|
+
retryAfterMs: retryAfterMs(res.headers.get("retry-after"))
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
return res.body;
|
|
653
|
+
}
|
|
654
|
+
async function* billedStream(ctx, userId, params, call) {
|
|
655
|
+
let grant = await requireGrant(ctx, userId);
|
|
656
|
+
grant = await ctx.tokens.ensureAccessToken(userId, grant);
|
|
657
|
+
const idempotencyKey = params.idempotencyKey ?? crypto.randomUUID();
|
|
658
|
+
const body = requestBody({ ...params, stream: true });
|
|
659
|
+
let res = await call(grant.accessToken, idempotencyKey, body, params.signal);
|
|
660
|
+
if (res.status === 401) {
|
|
661
|
+
grant = await ctx.tokens.forceRefresh(userId, grant);
|
|
662
|
+
res = await call(grant.accessToken, idempotencyKey, body, params.signal);
|
|
663
|
+
if (res.status === 401) {
|
|
664
|
+
await ctx.config.grantStore?.delete(userId);
|
|
665
|
+
throw WindError.fromResponse(res.status, res.body, { requestId: res.requestId });
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (res.status < 200 || res.status >= 300) {
|
|
669
|
+
throw WindError.fromResponse(res.status, res.body, { requestId: res.requestId });
|
|
670
|
+
}
|
|
671
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
672
|
+
if (!contentType.includes("text/event-stream")) {
|
|
673
|
+
const result = res.body;
|
|
674
|
+
yield { delta: result, wind: result.wind };
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
yield { delta: res.body, wind: null };
|
|
678
|
+
}
|
|
679
|
+
var TOLERANCE_S = 300;
|
|
680
|
+
var WebhookVerifier = class {
|
|
681
|
+
constructor(config) {
|
|
682
|
+
this.config = config;
|
|
683
|
+
}
|
|
684
|
+
config;
|
|
685
|
+
/**
|
|
686
|
+
* Verify and parse. Throws `WindSignatureError` on any mismatch, malformed
|
|
687
|
+
* header, missing secret, or stale timestamp. Returns the typed event.
|
|
688
|
+
*/
|
|
689
|
+
verify(input) {
|
|
690
|
+
if (!this.verifySignature(input)) {
|
|
691
|
+
throw new WindSignatureError("Wind webhook signature verification failed.");
|
|
692
|
+
}
|
|
693
|
+
const text = typeof input.payload === "string" ? input.payload : input.payload.toString("utf8");
|
|
694
|
+
let parsed;
|
|
695
|
+
try {
|
|
696
|
+
parsed = JSON.parse(text);
|
|
697
|
+
} catch (cause) {
|
|
698
|
+
throw new WindSignatureError("Wind webhook payload is not valid JSON.", { cause });
|
|
699
|
+
}
|
|
700
|
+
const event = parsed;
|
|
701
|
+
if (!event || typeof event.id !== "string" || typeof event.type !== "string") {
|
|
702
|
+
throw new WindSignatureError("Wind webhook payload is missing required fields (id, type).");
|
|
703
|
+
}
|
|
704
|
+
return event;
|
|
705
|
+
}
|
|
706
|
+
/** Constant-time HMAC check only — no parsing. Implement this first. */
|
|
707
|
+
verifySignature(input, now = this.config.clock()) {
|
|
708
|
+
if (!this.config.webhookSecret) {
|
|
709
|
+
throw new WindSignatureError("webhookSecret is not configured \u2014 cannot verify Wind webhooks.");
|
|
710
|
+
}
|
|
711
|
+
const match = /^t=(\d+),v1=([0-9a-f]+)$/i.exec(input.signature?.trim() ?? "");
|
|
712
|
+
if (!match) return false;
|
|
713
|
+
const [, timestampStr, providedHex] = match;
|
|
714
|
+
const timestamp = Number(timestampStr);
|
|
715
|
+
if (!Number.isFinite(timestamp)) return false;
|
|
716
|
+
if (Math.abs(now / 1e3 - timestamp) > TOLERANCE_S) return false;
|
|
717
|
+
const payload = typeof input.payload === "string" ? Buffer.from(input.payload, "utf8") : input.payload;
|
|
718
|
+
const signedContent = Buffer.concat([Buffer.from(`${timestampStr}.`, "utf8"), payload]);
|
|
719
|
+
const expected = crypto.createHmac("sha256", this.config.webhookSecret).update(signedContent).digest();
|
|
720
|
+
let provided;
|
|
721
|
+
try {
|
|
722
|
+
provided = Buffer.from(providedHex, "hex");
|
|
723
|
+
} catch {
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
return provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
// src/adapters/express.ts
|
|
731
|
+
function parseCookies(header) {
|
|
732
|
+
const raw = Array.isArray(header) ? header.join("; ") : header;
|
|
733
|
+
const out = {};
|
|
734
|
+
if (!raw) return out;
|
|
735
|
+
for (const part of raw.split(";")) {
|
|
736
|
+
const eq = part.indexOf("=");
|
|
737
|
+
if (eq < 0) continue;
|
|
738
|
+
const key = part.slice(0, eq).trim();
|
|
739
|
+
const value = part.slice(eq + 1).trim();
|
|
740
|
+
if (key) out[key] = decodeURIComponent(value);
|
|
741
|
+
}
|
|
742
|
+
return out;
|
|
743
|
+
}
|
|
744
|
+
function setCookie(res, name, value, opts = {}) {
|
|
745
|
+
const attrs = [`${name}=${opts.clear ? "" : encodeURIComponent(value)}`, "Path=/", "HttpOnly", "SameSite=Lax"];
|
|
746
|
+
if (process.env.NODE_ENV === "production") attrs.push("Secure");
|
|
747
|
+
attrs.push(opts.clear ? "Max-Age=0" : `Max-Age=${opts.maxAgeSeconds ?? Math.floor(TRANSACTION_TTL_MS / 1e3)}`);
|
|
748
|
+
res.setHeader("Set-Cookie", attrs.join("; "));
|
|
749
|
+
}
|
|
750
|
+
function getPath(req) {
|
|
751
|
+
if (req.path) return req.path;
|
|
752
|
+
const url = req.url ?? "/";
|
|
753
|
+
const q = url.indexOf("?");
|
|
754
|
+
return q >= 0 ? url.slice(0, q) : url;
|
|
755
|
+
}
|
|
756
|
+
function getQuery(req) {
|
|
757
|
+
if (req.query) {
|
|
758
|
+
const out = {};
|
|
759
|
+
for (const [k, v] of Object.entries(req.query)) out[k] = Array.isArray(v) ? String(v[0]) : String(v);
|
|
760
|
+
return out;
|
|
761
|
+
}
|
|
762
|
+
const url = req.url ?? "/";
|
|
763
|
+
const q = url.indexOf("?");
|
|
764
|
+
if (q < 0) return {};
|
|
765
|
+
return Object.fromEntries(new URLSearchParams(url.slice(q + 1)));
|
|
766
|
+
}
|
|
767
|
+
function createExpressRoutes(flow, opts) {
|
|
768
|
+
const startPath = opts.startPath ?? DEFAULT_START_PATH;
|
|
769
|
+
const callbackPath = opts.callbackPath ?? DEFAULT_CALLBACK_PATH;
|
|
770
|
+
const grantStore = opts.grantStore ?? flow.config.grantStore;
|
|
771
|
+
const onConnected = opts.onConnected ?? flow.config.onConnected;
|
|
772
|
+
const successRedirect = opts.successRedirect ?? "/?wind=connected";
|
|
773
|
+
function handleError(err, req, res) {
|
|
774
|
+
if (opts.onError) {
|
|
775
|
+
opts.onError(err, req, res);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
console.error("[@usewind/node] connect flow error:", err);
|
|
779
|
+
const message = err instanceof WindError ? err.message : "Wind connect flow failed.";
|
|
780
|
+
res.status(400).send(message);
|
|
781
|
+
}
|
|
782
|
+
return (req, res, next) => {
|
|
783
|
+
const path = getPath(req);
|
|
784
|
+
if (path !== startPath && path !== callbackPath) return next();
|
|
785
|
+
if ((req.method ?? "GET").toUpperCase() !== "GET") return next();
|
|
786
|
+
void (async () => {
|
|
787
|
+
try {
|
|
788
|
+
if (path === startPath) {
|
|
789
|
+
const { url, tx: tx2 } = flow.buildAuthorizeRedirect({
|
|
790
|
+
requestedAllocationMc: opts.requestedAllocationMc,
|
|
791
|
+
features: opts.features
|
|
792
|
+
});
|
|
793
|
+
if (opts.session && opts.session !== "cookie") {
|
|
794
|
+
const id = stateToken();
|
|
795
|
+
await opts.session.put(id, tx2);
|
|
796
|
+
setCookie(res, TX_ID_COOKIE_NAME, id);
|
|
797
|
+
} else {
|
|
798
|
+
setCookie(res, TX_COOKIE_NAME, signTransactionCookie(tx2, flow.config.clientSecret));
|
|
799
|
+
}
|
|
800
|
+
res.redirect(url);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (!grantStore) {
|
|
804
|
+
throw new WindConfigError("grantStore is required for wind.routes()/wind.handler() but was not configured.");
|
|
805
|
+
}
|
|
806
|
+
const userId = await opts.currentUser(req);
|
|
807
|
+
if (!userId) {
|
|
808
|
+
throw new WindConfigError("currentUser(req) returned no user \u2014 Wind is not your login; sign the user in first.");
|
|
809
|
+
}
|
|
810
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
811
|
+
let tx = null;
|
|
812
|
+
if (opts.session && opts.session !== "cookie") {
|
|
813
|
+
const id = cookies[TX_ID_COOKIE_NAME];
|
|
814
|
+
tx = id ? await opts.session.take(id) : null;
|
|
815
|
+
setCookie(res, TX_ID_COOKIE_NAME, "", { clear: true });
|
|
816
|
+
} else {
|
|
817
|
+
tx = verifyTransactionCookie(cookies[TX_COOKIE_NAME], flow.config.clientSecret);
|
|
818
|
+
setCookie(res, TX_COOKIE_NAME, "", { clear: true });
|
|
819
|
+
}
|
|
820
|
+
const query = getQuery(req);
|
|
821
|
+
const grant = await flow.completeCallback({ query, tx });
|
|
822
|
+
await grantStore.set(userId, grant);
|
|
823
|
+
if (onConnected) {
|
|
824
|
+
try {
|
|
825
|
+
await onConnected(userId, grant);
|
|
826
|
+
} catch {
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
res.redirect(successRedirect);
|
|
830
|
+
} catch (err) {
|
|
831
|
+
handleError(err, req, res);
|
|
832
|
+
}
|
|
833
|
+
})();
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// src/adapters/fetch.ts
|
|
838
|
+
function parseCookies2(header) {
|
|
839
|
+
const out = {};
|
|
840
|
+
if (!header) return out;
|
|
841
|
+
for (const part of header.split(";")) {
|
|
842
|
+
const eq = part.indexOf("=");
|
|
843
|
+
if (eq < 0) continue;
|
|
844
|
+
const key = part.slice(0, eq).trim();
|
|
845
|
+
const value = part.slice(eq + 1).trim();
|
|
846
|
+
if (key) out[key] = decodeURIComponent(value);
|
|
847
|
+
}
|
|
848
|
+
return out;
|
|
849
|
+
}
|
|
850
|
+
function cookieAttrs(opts = {}) {
|
|
851
|
+
const attrs = ["Path=/", "HttpOnly", "SameSite=Lax"];
|
|
852
|
+
if (process.env.NODE_ENV === "production") attrs.push("Secure");
|
|
853
|
+
attrs.push(opts.clear ? "Max-Age=0" : `Max-Age=${opts.maxAgeSeconds ?? Math.floor(TRANSACTION_TTL_MS / 1e3)}`);
|
|
854
|
+
return attrs.join("; ");
|
|
855
|
+
}
|
|
856
|
+
function createFetchHandler(flow, opts) {
|
|
857
|
+
const startPath = opts.startPath ?? DEFAULT_START_PATH;
|
|
858
|
+
const callbackPath = opts.callbackPath ?? DEFAULT_CALLBACK_PATH;
|
|
859
|
+
const grantStore = opts.grantStore ?? flow.config.grantStore;
|
|
860
|
+
const onConnected = opts.onConnected ?? flow.config.onConnected;
|
|
861
|
+
const successRedirect = opts.successRedirect ?? "/?wind=connected";
|
|
862
|
+
function errorResponse(err) {
|
|
863
|
+
const message = err instanceof WindError ? err.message : "Wind connect flow failed.";
|
|
864
|
+
console.error("[@usewind/node] connect flow error:", err);
|
|
865
|
+
return new Response(message, { status: 400 });
|
|
866
|
+
}
|
|
867
|
+
return async (req) => {
|
|
868
|
+
const url = new URL(req.url);
|
|
869
|
+
if (req.method !== "GET") return new Response("Not found", { status: 404 });
|
|
870
|
+
if (url.pathname !== startPath && url.pathname !== callbackPath) return new Response("Not found", { status: 404 });
|
|
871
|
+
try {
|
|
872
|
+
if (url.pathname === startPath) {
|
|
873
|
+
const { url: authorizeUrl, tx: tx2 } = flow.buildAuthorizeRedirect({
|
|
874
|
+
requestedAllocationMc: opts.requestedAllocationMc,
|
|
875
|
+
features: opts.features
|
|
876
|
+
});
|
|
877
|
+
const headers = new Headers({ Location: authorizeUrl });
|
|
878
|
+
if (opts.session && opts.session !== "cookie") {
|
|
879
|
+
const id = stateToken();
|
|
880
|
+
await opts.session.put(id, tx2);
|
|
881
|
+
headers.append("Set-Cookie", `${TX_ID_COOKIE_NAME}=${encodeURIComponent(id)}; ${cookieAttrs()}`);
|
|
882
|
+
} else {
|
|
883
|
+
const signed = signTransactionCookie(tx2, flow.config.clientSecret);
|
|
884
|
+
headers.append("Set-Cookie", `${TX_COOKIE_NAME}=${encodeURIComponent(signed)}; ${cookieAttrs()}`);
|
|
885
|
+
}
|
|
886
|
+
return new Response(null, { status: 302, headers });
|
|
887
|
+
}
|
|
888
|
+
if (!grantStore) {
|
|
889
|
+
throw new WindConfigError("grantStore is required for wind.routes()/wind.handler() but was not configured.");
|
|
890
|
+
}
|
|
891
|
+
const userId = await opts.currentUser(req);
|
|
892
|
+
if (!userId) {
|
|
893
|
+
throw new WindConfigError("currentUser(req) returned no user \u2014 Wind is not your login; sign the user in first.");
|
|
894
|
+
}
|
|
895
|
+
const cookies = parseCookies2(req.headers.get("cookie"));
|
|
896
|
+
const clearHeaders = new Headers();
|
|
897
|
+
let tx = null;
|
|
898
|
+
if (opts.session && opts.session !== "cookie") {
|
|
899
|
+
const id = cookies[TX_ID_COOKIE_NAME];
|
|
900
|
+
tx = id ? await opts.session.take(id) : null;
|
|
901
|
+
clearHeaders.append("Set-Cookie", `${TX_ID_COOKIE_NAME}=; ${cookieAttrs({ clear: true })}`);
|
|
902
|
+
} else {
|
|
903
|
+
tx = verifyTransactionCookie(cookies[TX_COOKIE_NAME], flow.config.clientSecret);
|
|
904
|
+
clearHeaders.append("Set-Cookie", `${TX_COOKIE_NAME}=; ${cookieAttrs({ clear: true })}`);
|
|
905
|
+
}
|
|
906
|
+
const query = {
|
|
907
|
+
code: url.searchParams.get("code") ?? void 0,
|
|
908
|
+
state: url.searchParams.get("state") ?? void 0,
|
|
909
|
+
error: url.searchParams.get("error") ?? void 0
|
|
910
|
+
};
|
|
911
|
+
const grant = await flow.completeCallback({ query, tx });
|
|
912
|
+
await grantStore.set(userId, grant);
|
|
913
|
+
if (onConnected) {
|
|
914
|
+
try {
|
|
915
|
+
await onConnected(userId, grant);
|
|
916
|
+
} catch {
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
clearHeaders.set("Location", successRedirect);
|
|
920
|
+
return new Response(null, { status: 302, headers: clearHeaders });
|
|
921
|
+
} catch (err) {
|
|
922
|
+
if (opts.onError) opts.onError(err, req, void 0);
|
|
923
|
+
return errorResponse(err);
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// src/index.ts
|
|
929
|
+
var Wind = class {
|
|
930
|
+
#partial;
|
|
931
|
+
#resolved = null;
|
|
932
|
+
#raw = null;
|
|
933
|
+
#flow = null;
|
|
934
|
+
#tokens = null;
|
|
935
|
+
#webhooks = null;
|
|
936
|
+
constructor(config = {}) {
|
|
937
|
+
this.#partial = config;
|
|
938
|
+
}
|
|
939
|
+
/** Set or override configuration. Call once at boot. Resets memoised internals. */
|
|
940
|
+
configure(config) {
|
|
941
|
+
this.#partial = { ...this.#partial, ...config };
|
|
942
|
+
this.#resolved = this.#raw = this.#flow = this.#tokens = this.#webhooks = null;
|
|
943
|
+
return this;
|
|
944
|
+
}
|
|
945
|
+
// ── lazily-built internals ────────────────────────────────────────────────
|
|
946
|
+
get #config() {
|
|
947
|
+
return this.#resolved ??= resolveConfig(this.#partial);
|
|
948
|
+
}
|
|
949
|
+
/** The low-level HTTP client — one method per openapi path. Escape hatch. */
|
|
950
|
+
get raw() {
|
|
951
|
+
return this.#raw ??= new RawClient(this.#config);
|
|
952
|
+
}
|
|
953
|
+
get #oauth() {
|
|
954
|
+
return this.#flow ??= new OAuthFlow(this.#config, this.raw);
|
|
955
|
+
}
|
|
956
|
+
get #tokenManager() {
|
|
957
|
+
return this.#tokens ??= new TokenManager(this.#config, this.raw);
|
|
958
|
+
}
|
|
959
|
+
get #verifier() {
|
|
960
|
+
return this.#webhooks ??= new WebhookVerifier(this.#config);
|
|
961
|
+
}
|
|
962
|
+
#scopeContext() {
|
|
963
|
+
return { config: this.#config, raw: this.raw, tokens: this.#tokenManager };
|
|
964
|
+
}
|
|
965
|
+
// ── connect flow ─────────────────────────────────────────────────────────
|
|
966
|
+
/** Express middleware owning `startPath` + `callbackPath`. */
|
|
967
|
+
routes(opts) {
|
|
968
|
+
return createExpressRoutes(this.#oauth, opts);
|
|
969
|
+
}
|
|
970
|
+
/** Web-standard `(Request) => Response` handler for the same two routes. */
|
|
971
|
+
handler(opts) {
|
|
972
|
+
return createFetchHandler(this.#oauth, opts);
|
|
973
|
+
}
|
|
974
|
+
// ── billed calls ─────────────────────────────────────────────────────────
|
|
975
|
+
/**
|
|
976
|
+
* Scope subsequent calls to one user of your app. Loads their grant via
|
|
977
|
+
* `grantStore.get`. Singular, one user's connection — distinct from the
|
|
978
|
+
* plural `wind.connections` below (client-credentials management across
|
|
979
|
+
* every connection).
|
|
980
|
+
*/
|
|
981
|
+
connection(userId) {
|
|
982
|
+
return new ConnectionScope(this.#scopeContext(), userId);
|
|
983
|
+
}
|
|
984
|
+
// ── management (client-credentials) ──────────────────────────────────────
|
|
985
|
+
connections = {
|
|
986
|
+
list: () => this.raw.listConnections(),
|
|
987
|
+
get: (id) => this.raw.getConnection(id),
|
|
988
|
+
revoke: (id) => this.raw.revokeConnection(id),
|
|
989
|
+
requestIncrease: (id, p) => this.raw.createAllocationRequest(id, { target_allocation_mc: p.targetAllocationMc, reason: p.reason })
|
|
990
|
+
};
|
|
991
|
+
account() {
|
|
992
|
+
return this.raw.getAccount();
|
|
993
|
+
}
|
|
994
|
+
features = {
|
|
995
|
+
list: (appId) => this.raw.listFeatures(appId),
|
|
996
|
+
register: (appId, feature) => this.raw.registerFeature(appId, feature),
|
|
997
|
+
update: (appId, slug, patch) => this.raw.updateFeature(appId, slug, patch)
|
|
998
|
+
};
|
|
999
|
+
// ── webhooks ─────────────────────────────────────────────────────────────
|
|
1000
|
+
/** Verify a webhook's `X-Wind-Signature` and return the typed event. Needs the raw body. */
|
|
1001
|
+
verify(input) {
|
|
1002
|
+
return this.#verifier.verify(input);
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
function createWind(config = {}) {
|
|
1006
|
+
return new Wind(config);
|
|
1007
|
+
}
|
|
1008
|
+
var wind = new Wind();
|
|
1009
|
+
|
|
1010
|
+
exports.AllocationExhaustedError = AllocationExhaustedError;
|
|
1011
|
+
exports.AppSuspendedError = AppSuspendedError;
|
|
1012
|
+
exports.ConnectionFrozenError = ConnectionFrozenError;
|
|
1013
|
+
exports.ConnectionRevokedError = ConnectionRevokedError;
|
|
1014
|
+
exports.ConnectionScope = ConnectionScope;
|
|
1015
|
+
exports.FeatureCostExceededError = FeatureCostExceededError;
|
|
1016
|
+
exports.FeatureModelNotConfiguredError = FeatureModelNotConfiguredError;
|
|
1017
|
+
exports.FeatureScope = FeatureScope;
|
|
1018
|
+
exports.IdempotencyKeyReusedError = IdempotencyKeyReusedError;
|
|
1019
|
+
exports.ModelNotAllowedError = ModelNotAllowedError;
|
|
1020
|
+
exports.ProviderError = ProviderError;
|
|
1021
|
+
exports.RateLimitedError = RateLimitedError;
|
|
1022
|
+
exports.RiskThrottledError = RiskThrottledError;
|
|
1023
|
+
exports.TokenExpiredError = TokenExpiredError;
|
|
1024
|
+
exports.Wind = Wind;
|
|
1025
|
+
exports.WindApiError = WindApiError;
|
|
1026
|
+
exports.WindConfigError = WindConfigError;
|
|
1027
|
+
exports.WindError = WindError;
|
|
1028
|
+
exports.WindNotConnectedError = WindNotConnectedError;
|
|
1029
|
+
exports.WindNotImplementedError = WindNotImplementedError;
|
|
1030
|
+
exports.WindSignatureError = WindSignatureError;
|
|
1031
|
+
exports.createWind = createWind;
|
|
1032
|
+
exports.memoryGrantStore = memoryGrantStore;
|
|
1033
|
+
exports.wind = wind;
|
|
1034
|
+
//# sourceMappingURL=index.cjs.map
|
|
1035
|
+
//# sourceMappingURL=index.cjs.map
|