@apostra/sdk 0.0.0-stage → 0.38.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/LICENSE +201 -0
- package/README.md +117 -2
- package/SKILL.md +29 -0
- package/dist/generated/client.d.ts +1092 -0
- package/dist/generated/client.js +606 -0
- package/dist/generated/metadata.d.ts +989 -0
- package/dist/generated/metadata.js +1339 -0
- package/dist/generated/types.gen.d.ts +35171 -0
- package/dist/generated/types.gen.js +2 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/transport.d.ts +142 -0
- package/dist/transport.js +600 -0
- package/dist/webhooks.d.ts +24 -0
- package/dist/webhooks.js +49 -0
- package/examples/first-value.ts +58 -0
- package/llms.txt +11 -0
- package/package.json +60 -4
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
import { baseUrl, m2mTokenUrl, operations, version, } from './generated/metadata.js';
|
|
2
|
+
export class ApostraError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
error;
|
|
5
|
+
requestId;
|
|
6
|
+
code;
|
|
7
|
+
recovery;
|
|
8
|
+
retryAfter;
|
|
9
|
+
retryable;
|
|
10
|
+
constructor(status, error, requestId) {
|
|
11
|
+
// Server messages/details are available explicitly. Do not include them in
|
|
12
|
+
// the default exception string: they can contain reflected request secrets.
|
|
13
|
+
const code = error.code;
|
|
14
|
+
const recovery = error.recovery ?? recoveryForStatus(status);
|
|
15
|
+
const retryAfter = error.retry_after ?? null;
|
|
16
|
+
super(`Apostra request failed (${status} ${code}${requestId ? `, request ${requestId}` : ''})`);
|
|
17
|
+
this.status = status;
|
|
18
|
+
this.error = error;
|
|
19
|
+
this.requestId = requestId;
|
|
20
|
+
this.name = 'ApostraError';
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.recovery = recovery;
|
|
23
|
+
this.retryAfter = retryAfter;
|
|
24
|
+
this.retryable = recovery === 'transient';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export class AuthenticationError extends ApostraError {
|
|
28
|
+
}
|
|
29
|
+
export class PermissionError extends ApostraError {
|
|
30
|
+
}
|
|
31
|
+
export class NotFoundError extends ApostraError {
|
|
32
|
+
}
|
|
33
|
+
export class ConflictError extends ApostraError {
|
|
34
|
+
}
|
|
35
|
+
export class RateLimitError extends ApostraError {
|
|
36
|
+
}
|
|
37
|
+
export class ValidationError extends ApostraError {
|
|
38
|
+
}
|
|
39
|
+
/** @deprecated This compatibility export is never thrown by the transport. */
|
|
40
|
+
export class UnsupportedCapabilityError extends Error {
|
|
41
|
+
}
|
|
42
|
+
export class ConnectionError extends Error {
|
|
43
|
+
code = 'CONNECTION_ERROR';
|
|
44
|
+
recovery = 'transient';
|
|
45
|
+
retryAfter = null;
|
|
46
|
+
retryable = true;
|
|
47
|
+
requestId = null;
|
|
48
|
+
constructor(cause) {
|
|
49
|
+
super('Apostra connection failed', { cause });
|
|
50
|
+
this.name = 'ConnectionError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export class TimeoutError extends Error {
|
|
54
|
+
code = 'TIMEOUT';
|
|
55
|
+
recovery = 'transient';
|
|
56
|
+
retryAfter = null;
|
|
57
|
+
retryable = true;
|
|
58
|
+
requestId = null;
|
|
59
|
+
constructor(cause) {
|
|
60
|
+
super('Apostra request timed out', { cause });
|
|
61
|
+
this.name = 'TimeoutError';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export class ProtocolError extends Error {
|
|
65
|
+
status;
|
|
66
|
+
requestId;
|
|
67
|
+
constructor(status, requestId) {
|
|
68
|
+
super(`Unexpected Apostra response (${status})`);
|
|
69
|
+
this.status = status;
|
|
70
|
+
this.requestId = requestId;
|
|
71
|
+
this.name = 'ProtocolError';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The server accepted a write but cannot yet honestly report its completion.
|
|
76
|
+
*
|
|
77
|
+
* Callers retain the original operation-specific idempotency key; the SDK
|
|
78
|
+
* never makes another request or creates a replacement key.
|
|
79
|
+
*/
|
|
80
|
+
export class InFlightReceiptError extends Error {
|
|
81
|
+
requestId;
|
|
82
|
+
retryAfterMs;
|
|
83
|
+
receipt;
|
|
84
|
+
status = 202;
|
|
85
|
+
constructor(requestId, retryAfterMs, receipt) {
|
|
86
|
+
super('Apostra write is still in flight');
|
|
87
|
+
this.requestId = requestId;
|
|
88
|
+
this.retryAfterMs = retryAfterMs;
|
|
89
|
+
this.receipt = receipt;
|
|
90
|
+
this.name = 'InFlightReceiptError';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const account = (value) => {
|
|
94
|
+
if (value !== undefined && !/^[1-9][0-9]*$/.test(value))
|
|
95
|
+
throw new TypeError('accountId must be a positive integer string');
|
|
96
|
+
return value;
|
|
97
|
+
};
|
|
98
|
+
function record(value) {
|
|
99
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
100
|
+
}
|
|
101
|
+
function credentialPart(value, name) {
|
|
102
|
+
if (!value || /[\r\n]/.test(value))
|
|
103
|
+
throw new TypeError(`${name} must be a non-empty single-line string`);
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
function m2mScope(value) {
|
|
107
|
+
const scope = Array.isArray(value) ? value.join(' ') : value;
|
|
108
|
+
if (!scope.trim() || /[\r\n]/.test(scope))
|
|
109
|
+
throw new TypeError('scope must be a non-empty single-line string');
|
|
110
|
+
return scope;
|
|
111
|
+
}
|
|
112
|
+
function m2mTimeoutMs(value) {
|
|
113
|
+
const timeoutMs = value ?? 30_000;
|
|
114
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
115
|
+
throw new TypeError('timeoutMs must be a positive finite number');
|
|
116
|
+
return timeoutMs;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Acquire and cache an OAuth client-credentials token for server-side use.
|
|
120
|
+
*
|
|
121
|
+
* The returned provider performs one refresh for concurrent callers and refreshes
|
|
122
|
+
* 60 seconds before expiry. It never persists or logs a client secret or token.
|
|
123
|
+
*/
|
|
124
|
+
export function m2mTokenProvider({ clientId: rawClientId, clientSecret: rawClientSecret, scope: rawScope, fetch: suppliedFetch, timeoutMs: rawTimeoutMs, }) {
|
|
125
|
+
const clientId = credentialPart(rawClientId, 'clientId');
|
|
126
|
+
const clientSecret = credentialPart(rawClientSecret, 'clientSecret');
|
|
127
|
+
const scope = m2mScope(rawScope);
|
|
128
|
+
const fetcher = suppliedFetch ?? globalThis.fetch;
|
|
129
|
+
const timeoutMs = m2mTimeoutMs(rawTimeoutMs);
|
|
130
|
+
let token;
|
|
131
|
+
let expiresAt = 0;
|
|
132
|
+
let refreshing;
|
|
133
|
+
const refresh = async () => {
|
|
134
|
+
let response;
|
|
135
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
136
|
+
try {
|
|
137
|
+
response = await fetcher(m2mTokenUrl, {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: {
|
|
140
|
+
Accept: 'application/json',
|
|
141
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
142
|
+
},
|
|
143
|
+
body: new URLSearchParams({
|
|
144
|
+
client_id: clientId,
|
|
145
|
+
client_secret: clientSecret,
|
|
146
|
+
grant_type: 'client_credentials',
|
|
147
|
+
scope,
|
|
148
|
+
}),
|
|
149
|
+
redirect: 'error',
|
|
150
|
+
signal,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
catch (cause) {
|
|
154
|
+
if (signal.aborted)
|
|
155
|
+
throw new TimeoutError(cause);
|
|
156
|
+
throw new ConnectionError(cause);
|
|
157
|
+
}
|
|
158
|
+
if (!response.ok)
|
|
159
|
+
throw new Error(`M2M token request failed (${response.status})`);
|
|
160
|
+
let payload;
|
|
161
|
+
try {
|
|
162
|
+
payload = await response.json();
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
throw new Error('M2M token response is invalid');
|
|
166
|
+
}
|
|
167
|
+
if (!record(payload) ||
|
|
168
|
+
typeof payload.access_token !== 'string' ||
|
|
169
|
+
!payload.access_token ||
|
|
170
|
+
/[\r\n]/.test(payload.access_token) ||
|
|
171
|
+
typeof payload.expires_in !== 'number' ||
|
|
172
|
+
!Number.isFinite(payload.expires_in) ||
|
|
173
|
+
payload.expires_in <= 0)
|
|
174
|
+
throw new Error('M2M token response is invalid');
|
|
175
|
+
const result = payload;
|
|
176
|
+
token = result.access_token;
|
|
177
|
+
expiresAt = Date.now() + Math.max(0, result.expires_in * 1000 - 60_000);
|
|
178
|
+
return token;
|
|
179
|
+
};
|
|
180
|
+
return () => {
|
|
181
|
+
if (token && Date.now() < expiresAt)
|
|
182
|
+
return token;
|
|
183
|
+
refreshing ??= refresh().finally(() => {
|
|
184
|
+
refreshing = undefined;
|
|
185
|
+
});
|
|
186
|
+
return refreshing;
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function errorModel(value) {
|
|
190
|
+
return (record(value) &&
|
|
191
|
+
typeof value.code === 'string' &&
|
|
192
|
+
typeof value.message === 'string');
|
|
193
|
+
}
|
|
194
|
+
function inFlightReceipt(value) {
|
|
195
|
+
if (!record(value))
|
|
196
|
+
return false;
|
|
197
|
+
const data = value.data;
|
|
198
|
+
if (!record(data) || !record(data.receipt))
|
|
199
|
+
return false;
|
|
200
|
+
const receipt = data.receipt;
|
|
201
|
+
return (value.error === null &&
|
|
202
|
+
typeof receipt.id === 'string' &&
|
|
203
|
+
typeof receipt.operation === 'string' &&
|
|
204
|
+
typeof receipt.state === 'string' &&
|
|
205
|
+
['claimed', 'running', 'uncertain'].includes(receipt.state) &&
|
|
206
|
+
typeof receipt.stateVersion === 'number' &&
|
|
207
|
+
Number.isInteger(receipt.stateVersion) &&
|
|
208
|
+
receipt.stateVersion >= 1 &&
|
|
209
|
+
typeof receipt.stateChangedAt === 'string' &&
|
|
210
|
+
typeof receipt.pendingReason === 'string');
|
|
211
|
+
}
|
|
212
|
+
function retryAfterMs(value) {
|
|
213
|
+
if (!value)
|
|
214
|
+
return null;
|
|
215
|
+
const seconds = Number(value);
|
|
216
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
217
|
+
return seconds * 1000;
|
|
218
|
+
const at = Date.parse(value);
|
|
219
|
+
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
|
|
220
|
+
}
|
|
221
|
+
function retryDelayMs(error, attempt) {
|
|
222
|
+
const jitter = Math.random() * Math.min(10_000, 250 * 2 ** attempt);
|
|
223
|
+
const retryAfter = error instanceof ApostraError && error.retryAfter !== null
|
|
224
|
+
? error.retryAfter * 1000
|
|
225
|
+
: 0;
|
|
226
|
+
return Math.max(jitter, retryAfter);
|
|
227
|
+
}
|
|
228
|
+
function retryable(error) {
|
|
229
|
+
if (error instanceof ConnectionError)
|
|
230
|
+
return true;
|
|
231
|
+
if (error instanceof ApostraError)
|
|
232
|
+
return error.status === 429 || error.status >= 500;
|
|
233
|
+
return (error instanceof ProtocolError &&
|
|
234
|
+
(error.status === 429 || error.status >= 500));
|
|
235
|
+
}
|
|
236
|
+
function wait(ms, signal) {
|
|
237
|
+
return new Promise((resolve, reject) => {
|
|
238
|
+
const cleanup = () => signal.removeEventListener('abort', abort);
|
|
239
|
+
const timer = setTimeout(() => {
|
|
240
|
+
cleanup();
|
|
241
|
+
resolve();
|
|
242
|
+
}, ms);
|
|
243
|
+
const abort = () => {
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
cleanup();
|
|
246
|
+
reject(signal.reason);
|
|
247
|
+
};
|
|
248
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
249
|
+
if (signal.aborted)
|
|
250
|
+
abort();
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
function recoveryForStatus(status) {
|
|
254
|
+
if (status === 408 || status === 429 || status >= 500)
|
|
255
|
+
return 'transient';
|
|
256
|
+
if (status === 400 ||
|
|
257
|
+
status === 401 ||
|
|
258
|
+
status === 403 ||
|
|
259
|
+
status === 404 ||
|
|
260
|
+
status === 409 ||
|
|
261
|
+
status === 422)
|
|
262
|
+
return 'correctable';
|
|
263
|
+
return 'terminal';
|
|
264
|
+
}
|
|
265
|
+
function typedError(status, error, requestId) {
|
|
266
|
+
const args = [status, error, requestId];
|
|
267
|
+
if (status === 401)
|
|
268
|
+
return new AuthenticationError(...args);
|
|
269
|
+
if (status === 403)
|
|
270
|
+
return new PermissionError(...args);
|
|
271
|
+
if (status === 404)
|
|
272
|
+
return new NotFoundError(...args);
|
|
273
|
+
if (status === 409)
|
|
274
|
+
return new ConflictError(...args);
|
|
275
|
+
if (status === 429)
|
|
276
|
+
return new RateLimitError(...args);
|
|
277
|
+
if (status === 400 || status === 422)
|
|
278
|
+
return new ValidationError(...args);
|
|
279
|
+
return new ApostraError(...args);
|
|
280
|
+
}
|
|
281
|
+
export class Transport {
|
|
282
|
+
#options;
|
|
283
|
+
#url;
|
|
284
|
+
#fetch;
|
|
285
|
+
constructor(options = {}) {
|
|
286
|
+
const environment = globalThis.process?.env;
|
|
287
|
+
const hasExplicitCredential = [
|
|
288
|
+
options.apiKey,
|
|
289
|
+
options.accessToken,
|
|
290
|
+
options.tokenProvider,
|
|
291
|
+
].some((value) => value !== undefined);
|
|
292
|
+
const resolvedOptions = {
|
|
293
|
+
...options,
|
|
294
|
+
apiKey: hasExplicitCredential
|
|
295
|
+
? options.apiKey
|
|
296
|
+
: environment?.APOSTRA_API_KEY,
|
|
297
|
+
accountId: options.accountId ?? environment?.APOSTRA_ACCOUNT_ID,
|
|
298
|
+
baseUrl: options.baseUrl ?? environment?.APOSTRA_BASE_URL,
|
|
299
|
+
};
|
|
300
|
+
if ([
|
|
301
|
+
resolvedOptions.apiKey,
|
|
302
|
+
resolvedOptions.accessToken,
|
|
303
|
+
resolvedOptions.tokenProvider,
|
|
304
|
+
].filter((x) => x !== undefined).length !== 1)
|
|
305
|
+
throw new TypeError('Supply exactly one of apiKey, accessToken or tokenProvider');
|
|
306
|
+
const maxRetries = resolvedOptions.maxRetries ?? 2;
|
|
307
|
+
if (!Number.isInteger(maxRetries) || maxRetries < 0)
|
|
308
|
+
throw new TypeError('maxRetries must be a non-negative integer');
|
|
309
|
+
account(resolvedOptions.accountId);
|
|
310
|
+
const url = new URL(resolvedOptions.baseUrl ?? baseUrl);
|
|
311
|
+
if (url.username ||
|
|
312
|
+
url.password ||
|
|
313
|
+
url.search ||
|
|
314
|
+
url.hash ||
|
|
315
|
+
(url.protocol !== 'https:' &&
|
|
316
|
+
!(url.protocol === 'http:' &&
|
|
317
|
+
['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))))
|
|
318
|
+
throw new TypeError('Use an HTTPS base URL (HTTP is allowed only on loopback)');
|
|
319
|
+
this.#url = url.href.replace(/\/$/, '');
|
|
320
|
+
this.#options = { ...resolvedOptions, maxRetries };
|
|
321
|
+
this.#fetch = resolvedOptions.fetch ?? globalThis.fetch;
|
|
322
|
+
}
|
|
323
|
+
async request(operation, input, options = {}) {
|
|
324
|
+
return (await this.perform(operation, input, options)).data;
|
|
325
|
+
}
|
|
326
|
+
async requestDetailed(operation, input, options = {}) {
|
|
327
|
+
return this.perform(operation, input, options);
|
|
328
|
+
}
|
|
329
|
+
async perform(operation, input, options) {
|
|
330
|
+
const meta = operations[operation];
|
|
331
|
+
const timeout = options.timeoutMs ?? this.#options.timeoutMs ?? 30_000;
|
|
332
|
+
if (!Number.isFinite(timeout) || timeout <= 0)
|
|
333
|
+
throw new TypeError('timeoutMs must be positive');
|
|
334
|
+
const timeoutSignal = AbortSignal.timeout(timeout);
|
|
335
|
+
const signal = options.signal
|
|
336
|
+
? AbortSignal.any([options.signal, timeoutSignal])
|
|
337
|
+
: timeoutSignal;
|
|
338
|
+
const deadline = (promise) => abortable(promise, signal).catch((error) => {
|
|
339
|
+
if (timeoutSignal.aborted)
|
|
340
|
+
throw new TimeoutError(error);
|
|
341
|
+
throw error;
|
|
342
|
+
});
|
|
343
|
+
const throwIfAborted = () => {
|
|
344
|
+
try {
|
|
345
|
+
signal.throwIfAborted();
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
if (timeoutSignal.aborted)
|
|
349
|
+
throw new TimeoutError(error);
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
throwIfAborted();
|
|
354
|
+
const target = account(options.accountId ?? this.#options.accountId);
|
|
355
|
+
const token = await deadline(Promise.resolve().then(() => {
|
|
356
|
+
throwIfAborted();
|
|
357
|
+
return (this.#options.tokenProvider?.() ??
|
|
358
|
+
this.#options.apiKey ??
|
|
359
|
+
this.#options.accessToken);
|
|
360
|
+
}));
|
|
361
|
+
throwIfAborted();
|
|
362
|
+
if (!token || /[\r\n]/.test(token))
|
|
363
|
+
throw new TypeError('Credential source returned an empty or invalid token');
|
|
364
|
+
const headers = new Headers({
|
|
365
|
+
Authorization: `Bearer ${token}`,
|
|
366
|
+
Accept: meta.binary ? 'text/markdown' : 'application/json',
|
|
367
|
+
'User-Agent': `apostra-typescript/${version}`,
|
|
368
|
+
});
|
|
369
|
+
let requestInput = input;
|
|
370
|
+
const fields = requestInput;
|
|
371
|
+
const bodyKeyField = 'bodyKeyField' in meta ? meta.bodyKeyField : undefined;
|
|
372
|
+
if (target)
|
|
373
|
+
headers.set('X-SCOPE3-CUSTOMER-ID', target);
|
|
374
|
+
if (meta.idempotencyKey) {
|
|
375
|
+
const key = options.idempotencyKey;
|
|
376
|
+
if (typeof key !== 'string' || !/^[\x21-\x7e]{1,255}$/.test(key))
|
|
377
|
+
throw new TypeError('idempotencyKey must be a non-empty printable string');
|
|
378
|
+
headers.set('Idempotency-Key', key);
|
|
379
|
+
if (bodyKeyField) {
|
|
380
|
+
const supplied = fields[bodyKeyField];
|
|
381
|
+
if (supplied !== undefined && supplied !== key)
|
|
382
|
+
throw new TypeError(`${bodyKeyField} must match idempotencyKey for ${operation}`);
|
|
383
|
+
requestInput = { ...fields, [bodyKeyField]: key };
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
let path = meta.path;
|
|
387
|
+
const query = new URLSearchParams();
|
|
388
|
+
for (const param of meta.parameters) {
|
|
389
|
+
const value = fields[param.name];
|
|
390
|
+
if (value === undefined) {
|
|
391
|
+
if (param.required)
|
|
392
|
+
throw new TypeError(`Missing ${param.name}`);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (param.location === 'path')
|
|
396
|
+
path = path.replace(`{${param.name}}`, encodeURIComponent(String(value)));
|
|
397
|
+
else
|
|
398
|
+
query.set(param.name, String(value));
|
|
399
|
+
}
|
|
400
|
+
if (meta.body)
|
|
401
|
+
headers.set('Content-Type', 'application/json');
|
|
402
|
+
const url = `${this.#url}${path}${query.size ? `?${query}` : ''}`;
|
|
403
|
+
const retries = this.#options.maxRetries ?? 2;
|
|
404
|
+
for (let attempt = 0;; attempt++) {
|
|
405
|
+
try {
|
|
406
|
+
let response;
|
|
407
|
+
try {
|
|
408
|
+
response = await deadline(this.#fetch(url, {
|
|
409
|
+
method: meta.method,
|
|
410
|
+
headers,
|
|
411
|
+
body: meta.body ? JSON.stringify(requestInput) : undefined,
|
|
412
|
+
signal,
|
|
413
|
+
redirect: 'error',
|
|
414
|
+
}));
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
if (timeoutSignal.aborted)
|
|
418
|
+
throw new TimeoutError(error);
|
|
419
|
+
if (options.signal?.aborted)
|
|
420
|
+
throw error;
|
|
421
|
+
throw new ConnectionError(error);
|
|
422
|
+
}
|
|
423
|
+
const requestId = response.headers.get('x-request-id');
|
|
424
|
+
// A 202 is an in-flight/uncertain write receipt, never a completed result.
|
|
425
|
+
if (response.status === 202) {
|
|
426
|
+
let receiptEnvelope;
|
|
427
|
+
try {
|
|
428
|
+
receiptEnvelope = await deadline(response.json());
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
throwIfAborted();
|
|
432
|
+
if (!(error instanceof SyntaxError))
|
|
433
|
+
throw error;
|
|
434
|
+
throw new ProtocolError(response.status, requestId);
|
|
435
|
+
}
|
|
436
|
+
if (!inFlightReceipt(receiptEnvelope))
|
|
437
|
+
throw new ProtocolError(response.status, requestId);
|
|
438
|
+
throw new InFlightReceiptError(requestId, retryAfterMs(response.headers.get('retry-after')), receiptEnvelope.data.receipt);
|
|
439
|
+
}
|
|
440
|
+
if (meta.binary && response.ok)
|
|
441
|
+
return {
|
|
442
|
+
data: new Uint8Array(await deadline(response.arrayBuffer())),
|
|
443
|
+
status: response.status,
|
|
444
|
+
headers: response.headers,
|
|
445
|
+
requestId,
|
|
446
|
+
};
|
|
447
|
+
let envelope;
|
|
448
|
+
try {
|
|
449
|
+
envelope = await deadline(response.json());
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
throwIfAborted();
|
|
453
|
+
if (!(error instanceof SyntaxError))
|
|
454
|
+
throw error;
|
|
455
|
+
throw new ProtocolError(response.status, requestId);
|
|
456
|
+
}
|
|
457
|
+
if (!response.ok) {
|
|
458
|
+
if (record(envelope) &&
|
|
459
|
+
envelope.data === null &&
|
|
460
|
+
errorModel(envelope.error))
|
|
461
|
+
throw typedError(response.status, envelope.error, requestId);
|
|
462
|
+
throw new ProtocolError(response.status, requestId);
|
|
463
|
+
}
|
|
464
|
+
if (!record(envelope) ||
|
|
465
|
+
envelope.error !== null ||
|
|
466
|
+
!('data' in envelope))
|
|
467
|
+
throw new ProtocolError(response.status, requestId);
|
|
468
|
+
return {
|
|
469
|
+
data: envelope.data,
|
|
470
|
+
status: response.status,
|
|
471
|
+
headers: response.headers,
|
|
472
|
+
requestId,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
if (attempt >= retries || !retryable(error))
|
|
477
|
+
throw error;
|
|
478
|
+
await deadline(wait(retryDelayMs(error, attempt), signal));
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/** Re-invoke a caller-owned keyed write until an in-flight receipt settles. */
|
|
484
|
+
export async function settle(run, options = {}) {
|
|
485
|
+
const timeout = options.timeoutMs ?? 30_000;
|
|
486
|
+
if (!Number.isFinite(timeout) || timeout <= 0)
|
|
487
|
+
throw new TypeError('timeoutMs must be positive');
|
|
488
|
+
const timeoutSignal = AbortSignal.timeout(timeout);
|
|
489
|
+
const signal = options.signal
|
|
490
|
+
? AbortSignal.any([options.signal, timeoutSignal])
|
|
491
|
+
: timeoutSignal;
|
|
492
|
+
while (true) {
|
|
493
|
+
signal.throwIfAborted();
|
|
494
|
+
try {
|
|
495
|
+
return await abortable(run(signal), signal);
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
if (!(error instanceof InFlightReceiptError))
|
|
499
|
+
throw error;
|
|
500
|
+
await wait(error.retryAfterMs ?? 1000, signal);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/** Visit pages while preserving the exact caller-owned query and cursor rules. */
|
|
505
|
+
export async function* paginate(read, next, signal) {
|
|
506
|
+
let cursor;
|
|
507
|
+
const seen = new Set();
|
|
508
|
+
do {
|
|
509
|
+
signal?.throwIfAborted();
|
|
510
|
+
const page = await (signal
|
|
511
|
+
? abortable(read(cursor, signal), signal)
|
|
512
|
+
: read(cursor));
|
|
513
|
+
signal?.throwIfAborted();
|
|
514
|
+
const nextCursor = next(page) ?? undefined;
|
|
515
|
+
if (nextCursor && seen.has(nextCursor))
|
|
516
|
+
throw new ProtocolError(200, null);
|
|
517
|
+
if (nextCursor)
|
|
518
|
+
seen.add(nextCursor);
|
|
519
|
+
yield page;
|
|
520
|
+
cursor = nextCursor;
|
|
521
|
+
} while (cursor);
|
|
522
|
+
}
|
|
523
|
+
/** The caller supplies the contract's status predicate and keeps idempotency keys stable. */
|
|
524
|
+
export async function poll(read, complete, options) {
|
|
525
|
+
const interval = options.intervalMs ?? 1000;
|
|
526
|
+
if (!Number.isFinite(interval) || interval <= 0)
|
|
527
|
+
throw new TypeError('intervalMs must be positive');
|
|
528
|
+
while (true) {
|
|
529
|
+
options.signal.throwIfAborted();
|
|
530
|
+
const result = await abortable(read(options.signal), options.signal);
|
|
531
|
+
options.signal.throwIfAborted();
|
|
532
|
+
if (complete(result))
|
|
533
|
+
return result;
|
|
534
|
+
await new Promise((resolve, reject) => {
|
|
535
|
+
const cleanup = () => options.signal.removeEventListener('abort', abort);
|
|
536
|
+
const timer = setTimeout(() => {
|
|
537
|
+
cleanup();
|
|
538
|
+
resolve();
|
|
539
|
+
}, interval);
|
|
540
|
+
const abort = () => {
|
|
541
|
+
clearTimeout(timer);
|
|
542
|
+
cleanup();
|
|
543
|
+
reject(options.signal.reason);
|
|
544
|
+
};
|
|
545
|
+
options.signal.addEventListener('abort', abort, { once: true });
|
|
546
|
+
if (options.signal.aborted)
|
|
547
|
+
abort();
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
function handoffResume(value) {
|
|
552
|
+
return (record(value) &&
|
|
553
|
+
value.kind === 'manual' &&
|
|
554
|
+
value.reason ===
|
|
555
|
+
'This Page has no durable completion receipt. Read the affected object before continuing; opening or closing the browser is not completion.');
|
|
556
|
+
}
|
|
557
|
+
/** Return an actionable human handoff, or null when no supported URL is present. */
|
|
558
|
+
export function getHandoff(result) {
|
|
559
|
+
const handoff = result?.humanHandoff;
|
|
560
|
+
if (!record(handoff) ||
|
|
561
|
+
handoff.kind !== 'human_action_required' ||
|
|
562
|
+
typeof handoff.url !== 'string' ||
|
|
563
|
+
typeof handoff.expiresAt !== 'string' ||
|
|
564
|
+
!handoffResume(handoff.resume))
|
|
565
|
+
return null;
|
|
566
|
+
return {
|
|
567
|
+
kind: 'human_action_required',
|
|
568
|
+
url: handoff.url,
|
|
569
|
+
expiresAt: handoff.expiresAt,
|
|
570
|
+
resume: handoff.resume,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
/** Pass only a handoff returned by getHandoff; headless calls simply return its URL. */
|
|
574
|
+
export async function openHandoff(handoff, opener) {
|
|
575
|
+
const url = typeof handoff === 'string' ? handoff : handoff.url;
|
|
576
|
+
const parsed = new URL(url);
|
|
577
|
+
if (parsed.protocol !== 'https:' || parsed.username || parsed.password)
|
|
578
|
+
throw new TypeError('Expected an HTTPS handoff URL');
|
|
579
|
+
await opener?.(url);
|
|
580
|
+
return url;
|
|
581
|
+
}
|
|
582
|
+
function abortable(promise, signal) {
|
|
583
|
+
return new Promise((resolve, reject) => {
|
|
584
|
+
const cleanup = () => signal.removeEventListener('abort', abort);
|
|
585
|
+
const abort = () => {
|
|
586
|
+
cleanup();
|
|
587
|
+
reject(signal.reason);
|
|
588
|
+
};
|
|
589
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
590
|
+
promise.then((value) => {
|
|
591
|
+
cleanup();
|
|
592
|
+
resolve(value);
|
|
593
|
+
}, (error) => {
|
|
594
|
+
cleanup();
|
|
595
|
+
reject(error);
|
|
596
|
+
});
|
|
597
|
+
if (signal.aborted)
|
|
598
|
+
abort();
|
|
599
|
+
});
|
|
600
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type WebhookSecret = string | {
|
|
2
|
+
secret: string;
|
|
3
|
+
acceptsUntil?: number;
|
|
4
|
+
};
|
|
5
|
+
export type WebhookHeaders = {
|
|
6
|
+
signature: readonly string[];
|
|
7
|
+
timestamp: readonly string[];
|
|
8
|
+
deliveryId: readonly string[];
|
|
9
|
+
};
|
|
10
|
+
export type WebhookVerification = {
|
|
11
|
+
status: 'verified';
|
|
12
|
+
deliveryId: string;
|
|
13
|
+
secretId: string;
|
|
14
|
+
timestamp: number;
|
|
15
|
+
} | {
|
|
16
|
+
status: 'malformed' | 'unknown_secret' | 'expired_secret' | 'invalid_signature' | 'invalid_clock' | 'stale';
|
|
17
|
+
};
|
|
18
|
+
/** Verify one duplicate-preserving V3 webhook delivery before parsing its body. */
|
|
19
|
+
export declare function verifyWebhook(input: {
|
|
20
|
+
rawBody: Uint8Array | string;
|
|
21
|
+
headers: WebhookHeaders;
|
|
22
|
+
secrets: Readonly<Record<string, WebhookSecret>>;
|
|
23
|
+
nowUnixSeconds?: number;
|
|
24
|
+
}): WebhookVerification;
|
package/dist/webhooks.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
const webhookSignature = /^v1,kid=([A-Za-z0-9_-]{1,128}),hmac-sha256=([a-f0-9]{64})$/;
|
|
3
|
+
const webhookTimestamp = /^[1-9][0-9]{9,12}$/;
|
|
4
|
+
const webhookDeliveryId = /^[!-~]{1,128}$/;
|
|
5
|
+
const exactlyOne = (values) => Array.isArray(values) && values.length === 1 && typeof values[0] === 'string'
|
|
6
|
+
? values[0]
|
|
7
|
+
: null;
|
|
8
|
+
/** Verify one duplicate-preserving V3 webhook delivery before parsing its body. */
|
|
9
|
+
export function verifyWebhook(input) {
|
|
10
|
+
const nowUnixSeconds = input.nowUnixSeconds ?? Math.floor(Date.now() / 1000);
|
|
11
|
+
if (!Number.isSafeInteger(nowUnixSeconds) || nowUnixSeconds < 0)
|
|
12
|
+
return { status: 'invalid_clock' };
|
|
13
|
+
const signatureHeader = exactlyOne(input.headers.signature);
|
|
14
|
+
const timestampHeader = exactlyOne(input.headers.timestamp);
|
|
15
|
+
const deliveryId = exactlyOne(input.headers.deliveryId);
|
|
16
|
+
const signature = signatureHeader && webhookSignature.exec(signatureHeader);
|
|
17
|
+
if (!signature ||
|
|
18
|
+
!timestampHeader ||
|
|
19
|
+
!webhookTimestamp.test(timestampHeader) ||
|
|
20
|
+
!deliveryId ||
|
|
21
|
+
!webhookDeliveryId.test(deliveryId))
|
|
22
|
+
return { status: 'malformed' };
|
|
23
|
+
const timestamp = Number(timestampHeader);
|
|
24
|
+
if (!Number.isSafeInteger(timestamp))
|
|
25
|
+
return { status: 'malformed' };
|
|
26
|
+
const configured = input.secrets[signature[1]];
|
|
27
|
+
if (!configured)
|
|
28
|
+
return { status: 'unknown_secret' };
|
|
29
|
+
const secret = typeof configured === 'string' ? configured : configured.secret;
|
|
30
|
+
const acceptsUntil = typeof configured === 'string' ? undefined : configured.acceptsUntil;
|
|
31
|
+
if (!secret ||
|
|
32
|
+
(acceptsUntil !== undefined &&
|
|
33
|
+
(!Number.isSafeInteger(acceptsUntil) || nowUnixSeconds > acceptsUntil)))
|
|
34
|
+
return { status: 'expired_secret' };
|
|
35
|
+
const body = Buffer.from(input.rawBody);
|
|
36
|
+
const expected = createHmac('sha256', secret)
|
|
37
|
+
.update(Buffer.concat([
|
|
38
|
+
Buffer.from(`v1.${timestampHeader}.${deliveryId}.`),
|
|
39
|
+
body,
|
|
40
|
+
]))
|
|
41
|
+
.digest();
|
|
42
|
+
const supplied = Buffer.from(signature[2], 'hex');
|
|
43
|
+
if (supplied.length !== expected.length ||
|
|
44
|
+
!timingSafeEqual(supplied, expected))
|
|
45
|
+
return { status: 'invalid_signature' };
|
|
46
|
+
if (Math.abs(nowUnixSeconds - timestamp) > 300)
|
|
47
|
+
return { status: 'stale' };
|
|
48
|
+
return { status: 'verified', deliveryId, secretId: signature[1], timestamp };
|
|
49
|
+
}
|