yungle-client 0.1.0 → 0.2.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 +1 -1
- package/README.md +26 -11
- package/dist/index.d.ts +97 -10
- package/dist/index.mjs +110 -12
- package/dist/types.d.ts +49 -1
- package/package.json +16 -15
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -13,11 +13,11 @@ import { YungleClient } from 'yungle-client';
|
|
|
13
13
|
|
|
14
14
|
const yungle = new YungleClient({ apiKey: process.env.YUNGLE_API_KEY! });
|
|
15
15
|
|
|
16
|
-
//
|
|
16
|
+
// Everything here works on the free plan.
|
|
17
17
|
const { transfers } = await yungle.listTransfers();
|
|
18
18
|
const { downloads } = await yungle.transferDownloads(transfers[0].id);
|
|
19
19
|
|
|
20
|
-
// Sending
|
|
20
|
+
// Sending works on the free plan too; collections need a paid one.
|
|
21
21
|
const draft = await yungle.createTransfer({
|
|
22
22
|
files: [{ name: 'final-cut.mov', size: 8_123_456_789 }],
|
|
23
23
|
});
|
|
@@ -25,16 +25,18 @@ const draft = await yungle.createTransfer({
|
|
|
25
25
|
await yungle.finalizeTransfer(draft.transfer.id, { recipients: ['client@example.com'] });
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
Get a key from Settings → API keys.
|
|
29
|
-
|
|
28
|
+
Get a key from Settings → API keys. Every account can use the API: transfers and contacts on the free plan,
|
|
29
|
+
collections on a paid one. The first 10 GB of API uploads each month are free.
|
|
30
30
|
|
|
31
31
|
## What it does and doesn't do
|
|
32
32
|
|
|
33
33
|
- **Dependency-free**, built on `fetch`, so it runs unchanged on Node 22+, Bun,
|
|
34
34
|
Deno and edge runtimes.
|
|
35
|
-
- **Retries
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
- **Retries are safe.** Throttles are always retried; 5xx and dropped connections are retried
|
|
36
|
+
for GET, DELETE and POST. Every POST carries an `Idempotency-Key`, reused on each retry, so a
|
|
37
|
+
lost response to `createTransfer` gets the first draft back instead of making a second.
|
|
38
|
+
- **Pages are walked for you.** Lists take `{ limit, cursor }` and return `nextCursor`;
|
|
39
|
+
`allTransfers()` and `allCollectionFiles(id)` are async iterators over everything.
|
|
38
40
|
- **Does not upload bytes.** Endpoints that accept files return a tus endpoint
|
|
39
41
|
and per-file tokens; streaming to those is the caller's job. Keeping the byte
|
|
40
42
|
pipeline out is what lets this stay one small file. If you want that handled
|
|
@@ -46,14 +48,27 @@ uploading come with a paid plan.
|
|
|
46
48
|
import { YungleApiError } from 'yungle-client';
|
|
47
49
|
|
|
48
50
|
try {
|
|
49
|
-
await yungle.
|
|
51
|
+
await yungle.createCollection({ title: 'Client deliveries' });
|
|
50
52
|
} catch (err) {
|
|
51
53
|
if (err instanceof YungleApiError && err.code === 'upgrade_required') {
|
|
52
|
-
//
|
|
54
|
+
// A free account wrote to a collection; collections come with a plan.
|
|
53
55
|
}
|
|
54
56
|
}
|
|
55
57
|
```
|
|
56
58
|
|
|
59
|
+
## Webhooks
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { verifyWebhook } from 'yungle-client';
|
|
63
|
+
|
|
64
|
+
const { secret } = await yungle.createWebhook({ url: 'https://example.com/hooks/yungle', events: ['transfer.downloaded'] });
|
|
65
|
+
// The secret is returned once. In your handler, with the RAW request body:
|
|
66
|
+
if (!(await verifyWebhook(rawBody, req.headers['yungle-signature'], secret))) return res.status(400).end();
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Signed with HMAC-SHA256 over a timestamp and the body; stale signatures (over 5 minutes) are
|
|
70
|
+
refused. No public URL? Create the endpoint with `url: null` and poll `listWebhookEvents(id, { cursor })` instead.
|
|
71
|
+
|
|
57
72
|
## Not reachable
|
|
58
73
|
|
|
59
74
|
Your vault and end-to-end encrypted transfers, because their keys are derived in
|
|
@@ -63,7 +78,7 @@ the client and never sent to Yungle. See
|
|
|
63
78
|
## Related
|
|
64
79
|
|
|
65
80
|
- [`yungle-cli`](https://www.npmjs.com/package/yungle-cli) — resumable uploads from your terminal
|
|
66
|
-
- [`yungle-mcp`](https://www.npmjs.com/package/yungle-mcp) —
|
|
67
|
-
- [
|
|
81
|
+
- [`yungle-mcp`](https://www.npmjs.com/package/yungle-mcp) — connect Claude or Cursor to Yungle
|
|
82
|
+
- [API reference](https://yungle.co/developers/reference?ref=npm) · [OpenAPI spec](https://yungle.co/api/v1/openapi.json)
|
|
68
83
|
|
|
69
84
|
MIT
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Collection, CollectionFile, CollectionSummary, Contact, ContactInput, DownloadEvent, FileInput, Folder, Guest, Me, RecipientStatus, Transfer, TransferFile, TransferSummary, UploadTargets } from './types';
|
|
2
|
-
export * from './types';
|
|
1
|
+
import type { Collection, CollectionFile, CollectionSummary, Contact, ContactInput, DownloadEvent, FileInput, Folder, Guest, Me, RecipientStatus, Transfer, TransferFile, TransferSummary, UploadTargets, WebhookDelivery, WebhookEndpoint, WebhookEvent, WebhookEventType } from './types.js';
|
|
2
|
+
export * from './types.js';
|
|
3
3
|
/**
|
|
4
4
|
* A typed client for the Yungle API.
|
|
5
5
|
*
|
|
@@ -44,17 +44,38 @@ export interface ClientOptions {
|
|
|
44
44
|
* Only ever applied to requests that are safe to repeat — see `request()`.
|
|
45
45
|
*/
|
|
46
46
|
maxRetries?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Sent as `User-Agent`. The CLI and the MCP server set their own; anything
|
|
49
|
+
* else is reported as this package, so Yungle can tell integrations apart.
|
|
50
|
+
*/
|
|
51
|
+
userAgent?: string;
|
|
47
52
|
}
|
|
53
|
+
export interface PageOptions {
|
|
54
|
+
/** Page size. The server's default applies when omitted. */
|
|
55
|
+
limit?: number;
|
|
56
|
+
/** The `nextCursor` from the previous page, unchanged. */
|
|
57
|
+
cursor?: string;
|
|
58
|
+
}
|
|
59
|
+
export declare const CLIENT_VERSION: string;
|
|
48
60
|
export declare class YungleClient {
|
|
49
61
|
private readonly baseUrl;
|
|
50
62
|
private readonly apiKey;
|
|
51
63
|
private readonly doFetch;
|
|
52
64
|
private readonly maxRetries;
|
|
65
|
+
private readonly userAgent;
|
|
53
66
|
constructor(options: ClientOptions);
|
|
54
67
|
me(): Promise<Me>;
|
|
55
|
-
|
|
68
|
+
/**
|
|
69
|
+
* One page of transfers, newest first — 100 by default, up to 500. Pass the
|
|
70
|
+
* returned `nextCursor` back as `cursor` for the next page; it is null on the
|
|
71
|
+
* last one. `allTransfers()` does the walking for you.
|
|
72
|
+
*/
|
|
73
|
+
listTransfers(page?: PageOptions): Promise<{
|
|
56
74
|
transfers: TransferSummary[];
|
|
75
|
+
nextCursor: string | null;
|
|
57
76
|
}>;
|
|
77
|
+
/** Every transfer, newest first, fetched a page at a time as you iterate. */
|
|
78
|
+
allTransfers(pageSize?: number): AsyncGenerator<TransferSummary>;
|
|
58
79
|
/**
|
|
59
80
|
* Create a draft. Nothing is live and nobody is emailed until `finalizeTransfer`.
|
|
60
81
|
*/
|
|
@@ -130,10 +151,17 @@ export declare class YungleClient {
|
|
|
130
151
|
deleteCollection(id: string): Promise<{
|
|
131
152
|
deleted: boolean;
|
|
132
153
|
}>;
|
|
133
|
-
/**
|
|
134
|
-
|
|
154
|
+
/**
|
|
155
|
+
* `folderId` omitted lists everything; `'root'` lists the top level only.
|
|
156
|
+
* Without `page.limit` the whole collection comes back in one response; with
|
|
157
|
+
* it, follow `nextCursor` (or use `allCollectionFiles`).
|
|
158
|
+
*/
|
|
159
|
+
listCollectionFiles(id: string, folderId?: string, page?: PageOptions): Promise<{
|
|
135
160
|
files: CollectionFile[];
|
|
161
|
+
nextCursor: string | null;
|
|
136
162
|
}>;
|
|
163
|
+
/** Every file in a collection, oldest first, a page at a time. */
|
|
164
|
+
allCollectionFiles(id: string, folderId?: string, pageSize?: number): AsyncGenerator<CollectionFile>;
|
|
137
165
|
addCollectionFiles(id: string, files: FileInput[], folderId?: string | null): Promise<UploadTargets>;
|
|
138
166
|
deleteCollectionFiles(id: string, fileIds: string[]): Promise<{
|
|
139
167
|
deleted: number;
|
|
@@ -156,6 +184,55 @@ export declare class YungleClient {
|
|
|
156
184
|
removeGuest(id: string, guestId: string): Promise<{
|
|
157
185
|
removed: boolean;
|
|
158
186
|
}>;
|
|
187
|
+
listWebhooks(): Promise<{
|
|
188
|
+
webhooks: WebhookEndpoint[];
|
|
189
|
+
}>;
|
|
190
|
+
/** `url: null` makes a pull endpoint. The `secret` is returned here only. */
|
|
191
|
+
createWebhook(input: {
|
|
192
|
+
url: string | null;
|
|
193
|
+
events: WebhookEventType[];
|
|
194
|
+
description?: string;
|
|
195
|
+
}): Promise<{
|
|
196
|
+
webhook: WebhookEndpoint;
|
|
197
|
+
secret: string;
|
|
198
|
+
}>;
|
|
199
|
+
getWebhook(id: string): Promise<{
|
|
200
|
+
webhook: WebhookEndpoint;
|
|
201
|
+
}>;
|
|
202
|
+
updateWebhook(id: string, input: {
|
|
203
|
+
url?: string | null;
|
|
204
|
+
events?: WebhookEventType[];
|
|
205
|
+
description?: string | null;
|
|
206
|
+
enabled?: boolean;
|
|
207
|
+
}): Promise<{
|
|
208
|
+
webhook: WebhookEndpoint;
|
|
209
|
+
}>;
|
|
210
|
+
deleteWebhook(id: string): Promise<{
|
|
211
|
+
deleted: boolean;
|
|
212
|
+
}>;
|
|
213
|
+
rotateWebhookSecret(id: string): Promise<{
|
|
214
|
+
secret: string;
|
|
215
|
+
}>;
|
|
216
|
+
testWebhook(id: string): Promise<{
|
|
217
|
+
deliveryId: string;
|
|
218
|
+
}>;
|
|
219
|
+
listWebhookDeliveries(id: string): Promise<{
|
|
220
|
+
deliveries: WebhookDelivery[];
|
|
221
|
+
}>;
|
|
222
|
+
retryWebhookDelivery(id: string, deliveryId: string): Promise<{
|
|
223
|
+
queued: boolean;
|
|
224
|
+
}>;
|
|
225
|
+
/**
|
|
226
|
+
* An endpoint's events, oldest first, after `cursor`. `nextCursor` comes back
|
|
227
|
+
* even when nothing is new, so keep the latest and poll with it.
|
|
228
|
+
*/
|
|
229
|
+
listWebhookEvents(id: string, page?: PageOptions): Promise<{
|
|
230
|
+
events: (WebhookEvent & {
|
|
231
|
+
deliveryId: string;
|
|
232
|
+
})[];
|
|
233
|
+
nextCursor: string | null;
|
|
234
|
+
hasMore: boolean;
|
|
235
|
+
}>;
|
|
159
236
|
listContacts(): Promise<{
|
|
160
237
|
contacts: Contact[];
|
|
161
238
|
}>;
|
|
@@ -173,12 +250,22 @@ export declare class YungleClient {
|
|
|
173
250
|
deleted: boolean;
|
|
174
251
|
}>;
|
|
175
252
|
/**
|
|
176
|
-
*
|
|
253
|
+
* What may be retried after a 5xx or a lost connection.
|
|
177
254
|
*
|
|
178
|
-
* GET and DELETE are safe by nature. POST is
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* *throttle* is always fine regardless: a 429 means nothing happened.
|
|
255
|
+
* GET and DELETE are safe by nature. A POST is safe because it carries an
|
|
256
|
+
* `Idempotency-Key` (see below). PATCH is not retried. Retrying a *throttle*
|
|
257
|
+
* is always fine regardless: a 429 means nothing happened.
|
|
182
258
|
*/
|
|
183
259
|
private request;
|
|
184
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* Check a webhook's `Yungle-Signature` header against the RAW request body.
|
|
263
|
+
*
|
|
264
|
+
* WebCrypto rather than `node:crypto`, so it runs wherever this client does:
|
|
265
|
+
* Node, Bun, Deno and edge runtimes. Rejects a timestamp more than
|
|
266
|
+
* `toleranceSeconds` away, which is what stops a captured request being replayed.
|
|
267
|
+
*/
|
|
268
|
+
export declare function verifyWebhook(rawBody: string, signatureHeader: string, secret: string, { toleranceSeconds, now }?: {
|
|
269
|
+
toleranceSeconds?: number;
|
|
270
|
+
now?: number;
|
|
271
|
+
}): Promise<boolean>;
|
package/dist/index.mjs
CHANGED
|
@@ -7,6 +7,9 @@ var YungleApiError = class extends Error {
|
|
|
7
7
|
this.details = details;
|
|
8
8
|
this.name = "YungleApiError";
|
|
9
9
|
}
|
|
10
|
+
status;
|
|
11
|
+
code;
|
|
12
|
+
details;
|
|
10
13
|
/** Worth trying again: throttled, or our fault. */
|
|
11
14
|
get retryable() {
|
|
12
15
|
return this.status === 429 || this.status >= 500;
|
|
@@ -18,25 +21,49 @@ var YungleApiError = class extends Error {
|
|
|
18
21
|
}
|
|
19
22
|
};
|
|
20
23
|
var DEFAULT_BASE = "https://yungle.co/api/v1";
|
|
24
|
+
function pageQuery(page, extra = {}) {
|
|
25
|
+
const params = new URLSearchParams(extra);
|
|
26
|
+
if (page.limit !== void 0) params.set("limit", String(page.limit));
|
|
27
|
+
if (page.cursor) params.set("cursor", page.cursor);
|
|
28
|
+
const q = params.toString();
|
|
29
|
+
return q ? `?${q}` : "";
|
|
30
|
+
}
|
|
31
|
+
var CLIENT_VERSION = true ? "0.2.0" : "dev";
|
|
21
32
|
var YungleClient = class {
|
|
22
33
|
baseUrl;
|
|
23
34
|
apiKey;
|
|
24
35
|
doFetch;
|
|
25
36
|
maxRetries;
|
|
37
|
+
userAgent;
|
|
26
38
|
constructor(options) {
|
|
27
39
|
if (!options.apiKey) throw new Error("An API key is required.");
|
|
28
40
|
this.apiKey = options.apiKey;
|
|
29
41
|
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE).replace(/\/+$/, "");
|
|
30
42
|
this.doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
31
43
|
this.maxRetries = options.maxRetries ?? 3;
|
|
44
|
+
this.userAgent = options.userAgent ?? `yungle-client/${CLIENT_VERSION}`;
|
|
32
45
|
}
|
|
33
46
|
// ── Account ───────────────────────────────────────────────────────────────
|
|
34
47
|
me() {
|
|
35
48
|
return this.request("GET", "/me");
|
|
36
49
|
}
|
|
37
50
|
// ── Transfers ─────────────────────────────────────────────────────────────
|
|
38
|
-
|
|
39
|
-
|
|
51
|
+
/**
|
|
52
|
+
* One page of transfers, newest first — 100 by default, up to 500. Pass the
|
|
53
|
+
* returned `nextCursor` back as `cursor` for the next page; it is null on the
|
|
54
|
+
* last one. `allTransfers()` does the walking for you.
|
|
55
|
+
*/
|
|
56
|
+
listTransfers(page = {}) {
|
|
57
|
+
return this.request("GET", `/transfers${pageQuery(page)}`);
|
|
58
|
+
}
|
|
59
|
+
/** Every transfer, newest first, fetched a page at a time as you iterate. */
|
|
60
|
+
async *allTransfers(pageSize = 500) {
|
|
61
|
+
let cursor;
|
|
62
|
+
do {
|
|
63
|
+
const page = await this.listTransfers({ limit: pageSize, cursor });
|
|
64
|
+
yield* page.transfers;
|
|
65
|
+
cursor = page.nextCursor ?? void 0;
|
|
66
|
+
} while (cursor);
|
|
40
67
|
}
|
|
41
68
|
/**
|
|
42
69
|
* Create a draft. Nothing is live and nobody is emailed until `finalizeTransfer`.
|
|
@@ -88,11 +115,24 @@ var YungleClient = class {
|
|
|
88
115
|
deleteCollection(id) {
|
|
89
116
|
return this.request("DELETE", `/collections/${enc(id)}`);
|
|
90
117
|
}
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
-
|
|
118
|
+
/**
|
|
119
|
+
* `folderId` omitted lists everything; `'root'` lists the top level only.
|
|
120
|
+
* Without `page.limit` the whole collection comes back in one response; with
|
|
121
|
+
* it, follow `nextCursor` (or use `allCollectionFiles`).
|
|
122
|
+
*/
|
|
123
|
+
listCollectionFiles(id, folderId, page = {}) {
|
|
124
|
+
const query = pageQuery(page, folderId ? { folderId } : {});
|
|
94
125
|
return this.request("GET", `/collections/${enc(id)}/files${query}`);
|
|
95
126
|
}
|
|
127
|
+
/** Every file in a collection, oldest first, a page at a time. */
|
|
128
|
+
async *allCollectionFiles(id, folderId, pageSize = 1e3) {
|
|
129
|
+
let cursor;
|
|
130
|
+
do {
|
|
131
|
+
const page = await this.listCollectionFiles(id, folderId, { limit: pageSize, cursor });
|
|
132
|
+
yield* page.files;
|
|
133
|
+
cursor = page.nextCursor ?? void 0;
|
|
134
|
+
} while (cursor);
|
|
135
|
+
}
|
|
96
136
|
addCollectionFiles(id, files, folderId) {
|
|
97
137
|
return this.request("POST", `/collections/${enc(id)}/files`, { files, folderId });
|
|
98
138
|
}
|
|
@@ -114,6 +154,42 @@ var YungleClient = class {
|
|
|
114
154
|
removeGuest(id, guestId) {
|
|
115
155
|
return this.request("DELETE", `/collections/${enc(id)}/guests/${enc(guestId)}`);
|
|
116
156
|
}
|
|
157
|
+
// ── Webhooks ──────────────────────────────────────────────────────────────
|
|
158
|
+
listWebhooks() {
|
|
159
|
+
return this.request("GET", "/webhooks");
|
|
160
|
+
}
|
|
161
|
+
/** `url: null` makes a pull endpoint. The `secret` is returned here only. */
|
|
162
|
+
createWebhook(input) {
|
|
163
|
+
return this.request("POST", "/webhooks", input);
|
|
164
|
+
}
|
|
165
|
+
getWebhook(id) {
|
|
166
|
+
return this.request("GET", `/webhooks/${enc(id)}`);
|
|
167
|
+
}
|
|
168
|
+
updateWebhook(id, input) {
|
|
169
|
+
return this.request("PATCH", `/webhooks/${enc(id)}`, input);
|
|
170
|
+
}
|
|
171
|
+
deleteWebhook(id) {
|
|
172
|
+
return this.request("DELETE", `/webhooks/${enc(id)}`);
|
|
173
|
+
}
|
|
174
|
+
rotateWebhookSecret(id) {
|
|
175
|
+
return this.request("POST", `/webhooks/${enc(id)}/rotate-secret`);
|
|
176
|
+
}
|
|
177
|
+
testWebhook(id) {
|
|
178
|
+
return this.request("POST", `/webhooks/${enc(id)}/test`);
|
|
179
|
+
}
|
|
180
|
+
listWebhookDeliveries(id) {
|
|
181
|
+
return this.request("GET", `/webhooks/${enc(id)}/deliveries`);
|
|
182
|
+
}
|
|
183
|
+
retryWebhookDelivery(id, deliveryId) {
|
|
184
|
+
return this.request("POST", `/webhooks/${enc(id)}/deliveries/${enc(deliveryId)}/retry`);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* An endpoint's events, oldest first, after `cursor`. `nextCursor` comes back
|
|
188
|
+
* even when nothing is new, so keep the latest and poll with it.
|
|
189
|
+
*/
|
|
190
|
+
listWebhookEvents(id, page = {}) {
|
|
191
|
+
return this.request("GET", `/webhooks/${enc(id)}/events${pageQuery(page)}`);
|
|
192
|
+
}
|
|
117
193
|
// ── Contacts ──────────────────────────────────────────────────────────────
|
|
118
194
|
listContacts() {
|
|
119
195
|
return this.request("GET", "/contacts");
|
|
@@ -133,15 +209,15 @@ var YungleClient = class {
|
|
|
133
209
|
}
|
|
134
210
|
// ── Transport ─────────────────────────────────────────────────────────────
|
|
135
211
|
/**
|
|
136
|
-
*
|
|
212
|
+
* What may be retried after a 5xx or a lost connection.
|
|
137
213
|
*
|
|
138
|
-
* GET and DELETE are safe by nature. POST is
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* *throttle* is always fine regardless: a 429 means nothing happened.
|
|
214
|
+
* GET and DELETE are safe by nature. A POST is safe because it carries an
|
|
215
|
+
* `Idempotency-Key` (see below). PATCH is not retried. Retrying a *throttle*
|
|
216
|
+
* is always fine regardless: a 429 means nothing happened.
|
|
142
217
|
*/
|
|
143
218
|
async request(method, path, body, opts) {
|
|
144
|
-
const
|
|
219
|
+
const idempotencyKey = method === "POST" ? globalThis.crypto.randomUUID() : void 0;
|
|
220
|
+
const safe = method === "GET" || method === "DELETE" || idempotencyKey !== void 0 || opts?.idempotent === true;
|
|
145
221
|
let attempt = 0;
|
|
146
222
|
for (; ; ) {
|
|
147
223
|
const res = await this.doFetch(`${this.baseUrl}${path}`, {
|
|
@@ -149,6 +225,8 @@ var YungleClient = class {
|
|
|
149
225
|
headers: {
|
|
150
226
|
Authorization: `Bearer ${this.apiKey}`,
|
|
151
227
|
Accept: "application/json",
|
|
228
|
+
"User-Agent": this.userAgent,
|
|
229
|
+
...idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {},
|
|
152
230
|
...body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
153
231
|
},
|
|
154
232
|
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
@@ -187,7 +265,27 @@ function backoffMs(attempt, error) {
|
|
|
187
265
|
function sleep(ms) {
|
|
188
266
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
189
267
|
}
|
|
268
|
+
async function verifyWebhook(rawBody, signatureHeader, secret, { toleranceSeconds = 300, now = Date.now() } = {}) {
|
|
269
|
+
const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
|
|
270
|
+
const t = Number(parts.t);
|
|
271
|
+
if (!Number.isInteger(t) || Math.abs(now / 1e3 - t) > toleranceSeconds || !parts.v1) return false;
|
|
272
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
273
|
+
"raw",
|
|
274
|
+
new TextEncoder().encode(secret),
|
|
275
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
276
|
+
false,
|
|
277
|
+
["sign"]
|
|
278
|
+
);
|
|
279
|
+
const mac = new Uint8Array(await globalThis.crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${t}.${rawBody}`)));
|
|
280
|
+
const expected = [...mac].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
281
|
+
if (expected.length !== parts.v1.length) return false;
|
|
282
|
+
let diff = 0;
|
|
283
|
+
for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ parts.v1.charCodeAt(i);
|
|
284
|
+
return diff === 0;
|
|
285
|
+
}
|
|
190
286
|
export {
|
|
287
|
+
CLIENT_VERSION,
|
|
191
288
|
YungleApiError,
|
|
192
|
-
YungleClient
|
|
289
|
+
YungleClient,
|
|
290
|
+
verifyWebhook
|
|
193
291
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -21,7 +21,8 @@ export interface Workspace {
|
|
|
21
21
|
id: string;
|
|
22
22
|
email: string | null;
|
|
23
23
|
displayName: string | null;
|
|
24
|
-
|
|
24
|
+
/** The workspace's verified custom domain, or null. */
|
|
25
|
+
customDomain: string | null;
|
|
25
26
|
}
|
|
26
27
|
export interface PlanInfo {
|
|
27
28
|
tier: string | null;
|
|
@@ -34,6 +35,10 @@ export interface KeyInfo {
|
|
|
34
35
|
id: string;
|
|
35
36
|
name: string;
|
|
36
37
|
scopes: string[];
|
|
38
|
+
/** Absent on servers before 2026-09-26. */
|
|
39
|
+
via?: 'key' | 'oauth';
|
|
40
|
+
/** Whether this credential may make Yungle email anyone. Absent on older servers. */
|
|
41
|
+
canEmail?: boolean;
|
|
37
42
|
}
|
|
38
43
|
export interface Me {
|
|
39
44
|
workspace: Workspace;
|
|
@@ -95,6 +100,15 @@ export interface RecipientStatus {
|
|
|
95
100
|
email: string;
|
|
96
101
|
notifiedAt: Iso8601 | null;
|
|
97
102
|
downloaded: boolean;
|
|
103
|
+
/** Loaded the link. Recorded from a browser, so link scanners do not count. */
|
|
104
|
+
opened: boolean;
|
|
105
|
+
/**
|
|
106
|
+
* Set when mail to this address permanently failed. Additive since 2026-09;
|
|
107
|
+
* clients that predate it are unaffected.
|
|
108
|
+
*/
|
|
109
|
+
bouncedAt: Iso8601 | null;
|
|
110
|
+
/** 'hard_bounce' | 'blocked' | 'refused', or null. */
|
|
111
|
+
bounceKind: string | null;
|
|
98
112
|
}
|
|
99
113
|
export interface DownloadEvent {
|
|
100
114
|
id: string;
|
|
@@ -103,6 +117,7 @@ export interface DownloadEvent {
|
|
|
103
117
|
recipientEmail: string | null;
|
|
104
118
|
/** One page visit is one download; several files in it share this. */
|
|
105
119
|
sessionId: string;
|
|
120
|
+
/** Always null since 2026-09 — the truncated IP is no longer recorded. */
|
|
106
121
|
ipTruncated: string | null;
|
|
107
122
|
createdAt: Iso8601;
|
|
108
123
|
}
|
|
@@ -173,3 +188,36 @@ export interface ContactInput {
|
|
|
173
188
|
phone?: string | null;
|
|
174
189
|
type?: string | null;
|
|
175
190
|
}
|
|
191
|
+
export type WebhookEventType = 'transfer.ready' | 'transfer.downloaded' | 'transfer.expiring' | 'transfer.expired' | 'collection.file_uploaded';
|
|
192
|
+
export interface WebhookEndpoint {
|
|
193
|
+
id: string;
|
|
194
|
+
/** Null for a pull endpoint. */
|
|
195
|
+
url: string | null;
|
|
196
|
+
mode: 'push' | 'pull';
|
|
197
|
+
description: string | null;
|
|
198
|
+
events: WebhookEventType[];
|
|
199
|
+
enabled: boolean;
|
|
200
|
+
disabledReason: 'failing' | 'user' | null;
|
|
201
|
+
lastSuccessAt: Iso8601 | null;
|
|
202
|
+
lastFailureAt: Iso8601 | null;
|
|
203
|
+
createdAt: Iso8601;
|
|
204
|
+
}
|
|
205
|
+
export interface WebhookDelivery {
|
|
206
|
+
id: string;
|
|
207
|
+
eventId: string;
|
|
208
|
+
type: WebhookEventType | 'webhook.test';
|
|
209
|
+
status: 'pending' | 'succeeded' | 'failed' | 'available';
|
|
210
|
+
attempts: number;
|
|
211
|
+
nextAttemptAt: Iso8601 | null;
|
|
212
|
+
lastStatusCode: number | null;
|
|
213
|
+
lastError: string | null;
|
|
214
|
+
deliveredAt: Iso8601 | null;
|
|
215
|
+
createdAt: Iso8601;
|
|
216
|
+
}
|
|
217
|
+
/** The JSON a push delivery sends, and what `/events` returns (plus `deliveryId`). */
|
|
218
|
+
export interface WebhookEvent<T = Record<string, unknown>> {
|
|
219
|
+
id: string;
|
|
220
|
+
type: WebhookEventType | 'webhook.test';
|
|
221
|
+
createdAt: Iso8601;
|
|
222
|
+
data: T;
|
|
223
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yungle-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -9,28 +9,20 @@
|
|
|
9
9
|
"default": "./dist/index.mjs"
|
|
10
10
|
}
|
|
11
11
|
},
|
|
12
|
-
"scripts": {
|
|
13
|
-
"build": "node build.mjs && tsc -p tsconfig.build.json",
|
|
14
|
-
"typecheck": "tsc --noEmit",
|
|
15
|
-
"test": "node --import tsx --test src/*.test.ts",
|
|
16
|
-
"lint": "eslint .",
|
|
17
|
-
"prepublishOnly": "node build.mjs && tsc -p tsconfig.build.json",
|
|
18
|
-
"prepare": "node build.mjs && tsc -p tsconfig.build.json"
|
|
19
|
-
},
|
|
20
12
|
"devDependencies": {
|
|
21
13
|
"@types/node": "^22.10.5",
|
|
22
14
|
"tsx": "^4.19.2",
|
|
23
15
|
"typescript": "^5.7.3",
|
|
24
|
-
"esbuild": "^0.
|
|
16
|
+
"esbuild": "^0.28.1"
|
|
25
17
|
},
|
|
26
|
-
"description": "Typed client for the Yungle API
|
|
18
|
+
"description": "Typed client for the Yungle API — send file transfers, build collections and sync contacts from Node.",
|
|
27
19
|
"license": "MIT",
|
|
28
|
-
"author": "
|
|
20
|
+
"author": "Brainpower <hello@yungle.co> (https://yungle.co)",
|
|
29
21
|
"repository": {
|
|
30
22
|
"type": "git",
|
|
31
23
|
"url": "git+https://github.com/heindewilde/yungle-clients.git"
|
|
32
24
|
},
|
|
33
|
-
"homepage": "https://yungle.co/developers",
|
|
25
|
+
"homepage": "https://yungle.co/developers/reference?ref=npm",
|
|
34
26
|
"bugs": {
|
|
35
27
|
"url": "https://github.com/heindewilde/yungle-clients/issues"
|
|
36
28
|
},
|
|
@@ -40,8 +32,12 @@
|
|
|
40
32
|
"keywords": [
|
|
41
33
|
"yungle",
|
|
42
34
|
"file-transfer",
|
|
35
|
+
"file-sharing",
|
|
43
36
|
"api-client",
|
|
44
37
|
"sdk",
|
|
38
|
+
"typescript",
|
|
39
|
+
"webhooks",
|
|
40
|
+
"upload",
|
|
45
41
|
"wetransfer-alternative",
|
|
46
42
|
"eu",
|
|
47
43
|
"gdpr"
|
|
@@ -51,5 +47,10 @@
|
|
|
51
47
|
"README.md",
|
|
52
48
|
"LICENSE"
|
|
53
49
|
],
|
|
54
|
-
"types": "./dist/index.d.ts"
|
|
55
|
-
|
|
50
|
+
"types": "./dist/index.d.ts",
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "node build.mjs",
|
|
53
|
+
"typecheck": "tsc --noEmit",
|
|
54
|
+
"test": "node --import tsx --test src/*.test.ts"
|
|
55
|
+
}
|
|
56
|
+
}
|