@medalsocial/sdk 1.5.0 → 1.7.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/dist/src/index.js CHANGED
@@ -21,6 +21,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var src_exports = {};
22
22
  __export(src_exports, {
23
23
  BaseClient: () => BaseClient,
24
+ Bookings: () => Bookings,
25
+ CAPABILITY_IDS: () => CAPABILITY_IDS,
26
+ CAPABILITY_ROUTES: () => CAPABILITY_ROUTES,
27
+ CapabilityConfirmations: () => CapabilityConfirmations,
28
+ CapabilityConfirmer: () => CapabilityConfirmer,
24
29
  Channels: () => Channels,
25
30
  Contacts: () => Contacts,
26
31
  DEFAULT_WEBHOOK_TOLERANCE_MS: () => DEFAULT_WEBHOOK_TOLERANCE_MS,
@@ -31,6 +36,7 @@ __export(src_exports, {
31
36
  Medal: () => Medal,
32
37
  MedalApiError: () => MedalApiError,
33
38
  Posts: () => Posts,
39
+ Scan: () => Scan,
34
40
  WebhookVerificationError: () => WebhookVerificationError,
35
41
  Webhooks: () => Webhooks,
36
42
  Workspaces: () => Workspaces,
@@ -55,6 +61,21 @@ var MedalApiError = class extends Error {
55
61
  };
56
62
 
57
63
  // src/client.ts
64
+ function randomIdempotencyKey() {
65
+ const webCrypto = globalThis.crypto;
66
+ if (typeof webCrypto.randomUUID === "function") {
67
+ return webCrypto.randomUUID();
68
+ }
69
+ const bytes = new Uint8Array(16);
70
+ webCrypto.getRandomValues(bytes);
71
+ bytes[6] = bytes[6] & 15 | 64;
72
+ bytes[8] = bytes[8] & 63 | 128;
73
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
74
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
75
+ }
76
+ function resolveIdempotencyKey(supplied) {
77
+ return (supplied ?? "").trim() || randomIdempotencyKey();
78
+ }
58
79
  var BaseClient = class {
59
80
  /** Resolved client configuration. */
60
81
  config;
@@ -74,6 +95,29 @@ var BaseClient = class {
74
95
  body: body !== void 0 ? JSON.stringify(body) : void 0
75
96
  });
76
97
  }
98
+ /**
99
+ * Execute a POST that must never execute twice, guaranteeing an
100
+ * `Idempotency-Key`.
101
+ *
102
+ * {@link BaseClient.post} retries 429 and 5xx automatically, so a write
103
+ * whose transaction committed before the gateway failed would otherwise be
104
+ * submitted a second time — booking the same slot twice. A key turns that
105
+ * retry into a replay: the server keys on the key, the workspace, and the
106
+ * method+path, and answers a repeat with the stored response, or 409 while
107
+ * the first attempt is still in flight. Either way the write happens once.
108
+ *
109
+ * The key is minted ONCE here, outside the retry loop in `request`, so every
110
+ * attempt of the same logical call carries the same value — a key minted per
111
+ * attempt would deduplicate nothing. A caller-supplied key always wins, so
112
+ * callers keeping their own records stay in control. See
113
+ * {@link resolveIdempotencyKey} for what counts as supplied.
114
+ */
115
+ async postOnce(path, body, options) {
116
+ return this.post(path, body, {
117
+ ...options,
118
+ idempotencyKey: resolveIdempotencyKey(options?.idempotencyKey)
119
+ });
120
+ }
77
121
  /** Execute an authenticated PATCH request with a JSON body. */
78
122
  async patch(path, body, options) {
79
123
  return this.request(this.buildUrl(path), {
@@ -125,12 +169,22 @@ var BaseClient = class {
125
169
  const controller = new AbortController();
126
170
  const timeout = setTimeout(() => controller.abort(), this.config.timeout);
127
171
  let res;
172
+ let text = "";
173
+ let retrying = false;
128
174
  try {
129
175
  res = await fetch(url, { ...init, headers, signal: controller.signal });
176
+ retrying = (res.status === 429 || res.status >= 500 && res.status <= 599) && attempt < maxAttempts;
177
+ if (retrying) {
178
+ const drained = res.body ? res.body.pipeTo(new WritableStream()) : res.text();
179
+ await drained.catch(() => {
180
+ });
181
+ } else {
182
+ text = await res.text();
183
+ }
130
184
  } finally {
131
185
  clearTimeout(timeout);
132
186
  }
133
- if ((res.status === 429 || res.status >= 500 && res.status <= 599) && attempt < maxAttempts) {
187
+ if (retrying) {
134
188
  const retryAfter = res.headers.get("retry-after");
135
189
  let delayMs = 0;
136
190
  if (retryAfter) {
@@ -143,7 +197,6 @@ var BaseClient = class {
143
197
  await new Promise((r) => setTimeout(r, delayMs));
144
198
  continue;
145
199
  }
146
- const text = await res.text();
147
200
  let parsed;
148
201
  try {
149
202
  parsed = text ? JSON.parse(text) : void 0;
@@ -165,12 +218,304 @@ var BaseClient = class {
165
218
  }
166
219
  };
167
220
 
221
+ // src/types/capabilities.ts
222
+ var CAPABILITY_IDS = [
223
+ "channel.connect_link.create.execute",
224
+ "channel.connect_link.revoke.execute",
225
+ "channel.connection.disconnect.execute",
226
+ "helpdesk.conversation.reply.execute",
227
+ "helpdesk.conversation.update.execute",
228
+ "helpdesk.webhook.create.execute",
229
+ "helpdesk.webhook.update.execute",
230
+ "helpdesk.webhook.delete.execute"
231
+ ];
232
+ var CAPABILITY_ROUTES = {
233
+ "channel.connect_link.create.execute": {
234
+ method: "POST",
235
+ path_template: "/api/v1/channels/connect-links"
236
+ },
237
+ "channel.connect_link.revoke.execute": {
238
+ method: "DELETE",
239
+ path_template: "/api/v1/channels/connect-links/{id}"
240
+ },
241
+ "channel.connection.disconnect.execute": {
242
+ method: "DELETE",
243
+ path_template: "/api/v1/channels/connections/{id}"
244
+ },
245
+ "helpdesk.conversation.reply.execute": {
246
+ method: "POST",
247
+ path_template: "/api/v1/helpdesk/replies"
248
+ },
249
+ "helpdesk.conversation.update.execute": {
250
+ method: "PATCH",
251
+ path_template: "/api/v1/helpdesk/conversations/{id}"
252
+ },
253
+ "helpdesk.webhook.create.execute": {
254
+ method: "POST",
255
+ path_template: "/api/v1/webhooks"
256
+ },
257
+ "helpdesk.webhook.update.execute": {
258
+ method: "PATCH",
259
+ path_template: "/api/v1/webhooks/{id}"
260
+ },
261
+ "helpdesk.webhook.delete.execute": {
262
+ method: "DELETE",
263
+ path_template: "/api/v1/webhooks/{id}"
264
+ }
265
+ };
266
+
267
+ // src/capability-confirmer.ts
268
+ function resolvePath(template, pathParams) {
269
+ return template.replace(/\{([^}/]+)\}/g, (_match, name) => {
270
+ const value = pathParams?.[name];
271
+ return value === void 0 ? `{${name}}` : encodeURIComponent(String(value));
272
+ });
273
+ }
274
+ var CapabilityConfirmer = class {
275
+ constructor(confirmations, defaults) {
276
+ this.confirmations = confirmations;
277
+ this.defaults = defaults;
278
+ }
279
+ confirmations;
280
+ defaults;
281
+ /**
282
+ * Return the request options to use for a confirmable write, minting the
283
+ * idempotency key and confirmation token first when auto-confirm is active.
284
+ *
285
+ * `body` is the pending request payload (`undefined` for `DELETE` routes).
286
+ * It is handed to the `previewSummary` callback by reference so the summary
287
+ * can describe the specific action, not just the route — it is the caller's
288
+ * own payload, so it is passed through unmodified and unredacted.
289
+ */
290
+ async prepare(request, pathParams, options) {
291
+ const auto = options?.autoConfirm === false ? void 0 : options?.autoConfirm ?? this.defaults;
292
+ if (!auto) return options;
293
+ const idempotencyKey = resolveIdempotencyKey(options?.idempotencyKey);
294
+ const callerKeyIsUsable = idempotencyKey === options?.idempotencyKey;
295
+ if (callerKeyIsUsable && options?.capabilityConfirmation) return options;
296
+ const route = CAPABILITY_ROUTES[request.capabilityId];
297
+ const path = resolvePath(route.path_template, pathParams);
298
+ const previewSummary = auto.previewSummary({
299
+ ...request,
300
+ method: route.method,
301
+ path,
302
+ ...pathParams ? { pathParams } : {},
303
+ idempotencyKey
304
+ });
305
+ if (typeof previewSummary !== "string" || previewSummary.trim() === "") {
306
+ throw new Error(
307
+ `autoConfirm.previewSummary must return a non-empty summary for ${request.capabilityId}. The summary is the audit record of what your user approved \u2014 refusing to assert user_approved: true without one.`
308
+ );
309
+ }
310
+ const { data } = await this.confirmations.create({
311
+ capability_id: request.capabilityId,
312
+ ...pathParams ? { path_params: pathParams } : {},
313
+ idempotency_key: idempotencyKey,
314
+ preview_summary: previewSummary,
315
+ user_approved: true
316
+ });
317
+ return {
318
+ ...options,
319
+ idempotencyKey,
320
+ capabilityConfirmation: data.confirmation_token
321
+ };
322
+ }
323
+ };
324
+
325
+ // src/resources/bookings.ts
326
+ var BookingsManage = class {
327
+ constructor(client) {
328
+ this.client = client;
329
+ }
330
+ client;
331
+ /**
332
+ * Read what the holder of a manage token may see and do. Honour
333
+ * `can_cancel` / `can_reschedule` — they already apply the policy windows.
334
+ */
335
+ async get(token) {
336
+ return this.client.get(`/api/v1/bookings/manage/${encodeURIComponent(token)}`);
337
+ }
338
+ /** Cancel on the customer's behalf. Rejected outside the cancel window. */
339
+ async cancel(token, input, options) {
340
+ return this.client.postOnce(
341
+ `/api/v1/bookings/manage/${encodeURIComponent(token)}/cancel`,
342
+ input ?? {},
343
+ options
344
+ );
345
+ }
346
+ /**
347
+ * Move the booking on the customer's behalf. Rejected outside the reschedule
348
+ * window. Returns a NEW booking id and a new manage token — the old token
349
+ * stops working, so relay the new one into whatever link you send next.
350
+ */
351
+ async reschedule(token, input, options) {
352
+ return this.client.postOnce(
353
+ `/api/v1/bookings/manage/${encodeURIComponent(token)}/reschedule`,
354
+ input,
355
+ options
356
+ );
357
+ }
358
+ };
359
+ var Bookings = class {
360
+ constructor(client) {
361
+ this.client = client;
362
+ this.manage = new BookingsManage(client);
363
+ }
364
+ client;
365
+ /** Customer-side actions addressed by manage token. */
366
+ manage;
367
+ /** List the bookable service catalogue. Active-only unless asked otherwise. */
368
+ async listServices(options) {
369
+ const params = {};
370
+ if (options?.include_inactive !== void 0) {
371
+ params.include_inactive = String(options.include_inactive);
372
+ }
373
+ return this.client.get("/api/v1/bookings/services", params);
374
+ }
375
+ /** List the bookable resources — staff, rooms, and equipment. */
376
+ async listResources() {
377
+ return this.client.get("/api/v1/bookings/resources");
378
+ }
379
+ /**
380
+ * List free slots for a service over a window. Slots reflect opening hours,
381
+ * time off, buffers, and existing bookings at the moment of the call — they
382
+ * are not held, so a slot can be taken before you book it.
383
+ */
384
+ async availability(options) {
385
+ const params = {
386
+ service_id: options.service_id,
387
+ from_ts: String(options.from_ts),
388
+ to_ts: String(options.to_ts)
389
+ };
390
+ if (options.resource_id) params.resource_id = options.resource_id;
391
+ return this.client.get("/api/v1/bookings/availability", params);
392
+ }
393
+ /**
394
+ * The dates a service can be booked on — the half `availability` cannot
395
+ * answer. Availability returns free slots and nothing else, so a closed day,
396
+ * an evening past closing and a fully booked day are all the same empty
397
+ * array. A date absent from this list is closed; on a listed date, compare
398
+ * `last_start_ts` against the clock to tell "too late today" from "full".
399
+ */
400
+ async schedule(options) {
401
+ const params = {
402
+ service_id: options.service_id,
403
+ from_ts: String(options.from_ts),
404
+ to_ts: String(options.to_ts)
405
+ };
406
+ if (options.resource_id) params.resource_id = options.resource_id;
407
+ return this.client.get("/api/v1/bookings/schedule", params);
408
+ }
409
+ /**
410
+ * List bookings with cursor-based pagination and optional filters.
411
+ *
412
+ * Check `pagination.truncated`: when true the read window was clipped and
413
+ * matching bookings exist that no cursor reaches — narrow `from_ts`/`to_ts`.
414
+ */
415
+ async list(options) {
416
+ const params = {};
417
+ if (options?.limit !== void 0) params.limit = String(options.limit);
418
+ if (options?.cursor) params.cursor = options.cursor;
419
+ if (options?.status) params.status = options.status;
420
+ if (options?.resource_id) params.resource_id = options.resource_id;
421
+ if (options?.from_ts !== void 0) params.from_ts = String(options.from_ts);
422
+ if (options?.to_ts !== void 0) params.to_ts = String(options.to_ts);
423
+ return this.client.get("/api/v1/bookings", params);
424
+ }
425
+ /**
426
+ * Book a party — every item succeeds or none do (max 50).
427
+ *
428
+ * Each created booking comes back with a `manage_token` exactly once; only
429
+ * its hash is stored, so persist it if you need the customer's manage link.
430
+ *
431
+ * Automatically idempotent: the SDK mints an `Idempotency-Key` so its own
432
+ * 5xx retries replay rather than book the slot twice. Supply
433
+ * `options.idempotencyKey` to deduplicate across your OWN retries too — the
434
+ * server keys on it for 24 hours, so re-sending the same key after a network
435
+ * timeout returns the original bookings instead of a second set.
436
+ */
437
+ async create(input, options) {
438
+ return this.client.postOnce("/api/v1/bookings", input, options);
439
+ }
440
+ /** Get a booking by ID. */
441
+ async get(id) {
442
+ return this.client.get(`/api/v1/bookings/${encodeURIComponent(id)}`);
443
+ }
444
+ /**
445
+ * Annotate a booking. At least one of `notes` (customer-visible) or
446
+ * `internal_notes` (staff-only) is required; `""` clears a field.
447
+ */
448
+ async update(id, input, options) {
449
+ return this.client.patch(`/api/v1/bookings/${encodeURIComponent(id)}`, input, options);
450
+ }
451
+ /** Cancel as the business — the cancel window is bypassed. */
452
+ async cancel(id, input, options) {
453
+ return this.client.postOnce(
454
+ `/api/v1/bookings/${encodeURIComponent(id)}/cancel`,
455
+ input ?? {},
456
+ options
457
+ );
458
+ }
459
+ /**
460
+ * Move a booking as the business — the reschedule window is bypassed.
461
+ * Returns a NEW booking id and a new manage token; the old booking is
462
+ * cancelled and its token stops working.
463
+ */
464
+ async reschedule(id, input, options) {
465
+ return this.client.postOnce(
466
+ `/api/v1/bookings/${encodeURIComponent(id)}/reschedule`,
467
+ input,
468
+ options
469
+ );
470
+ }
471
+ /** Mark a booking as a no-show. */
472
+ async markNoShow(id, options) {
473
+ return this.client.postOnce(
474
+ `/api/v1/bookings/${encodeURIComponent(id)}/no-show`,
475
+ void 0,
476
+ options
477
+ );
478
+ }
479
+ };
480
+
481
+ // src/resources/capability-confirmations.ts
482
+ var CapabilityConfirmations = class {
483
+ constructor(client) {
484
+ this.client = client;
485
+ }
486
+ client;
487
+ /**
488
+ * Issue a confirmation token for one pending write.
489
+ *
490
+ * The token is bound to the workspace, the auth subject, the capability's
491
+ * method + path, its required scopes, and `idempotency_key` — so it is
492
+ * usable exactly once, for exactly the write it describes, and expires
493
+ * within 15 minutes.
494
+ *
495
+ * Setting `user_approved: true` asserts that a human on your side approved
496
+ * this specific action. `preview_summary` is what they approved, and is
497
+ * retained for audit — write it for a human reader, not a log parser.
498
+ *
499
+ * Deliberately unkeyed, unlike the writes it authorizes. Minting is not the
500
+ * state change the guarantee exists to protect: the write itself is already
501
+ * bound to `idempotency_key`, so a retry that mints a second token cannot
502
+ * produce a second write. Keying this call would instead park a credential
503
+ * designed to expire in 15 minutes inside a replay cache that answers for 24
504
+ * hours — a worse trade than the duplicate token it would avoid.
505
+ */
506
+ async create(input) {
507
+ return this.client.post("/api/v1/capability-confirmations", input);
508
+ }
509
+ };
510
+
168
511
  // src/resources/channels.ts
169
512
  var ChannelConnectLinks = class {
170
- constructor(client) {
513
+ constructor(client, confirmer) {
171
514
  this.client = client;
515
+ this.confirmer = confirmer;
172
516
  }
173
517
  client;
518
+ confirmer;
174
519
  /**
175
520
  * Mint a single-use hosted connect link. Returns HTTP 201.
176
521
  *
@@ -181,30 +526,73 @@ var ChannelConnectLinks = class {
181
526
  *
182
527
  * Requires the `channel.connect.manage` scope; OAuth callers additionally
183
528
  * need the workspace `admin` role.
529
+ *
530
+ * Automatically idempotent: an unkeyed retry mints a SECOND live single-use
531
+ * link for the same person, and only one of the two can ever be consumed —
532
+ * the other stays outstanding until it is revoked or expires. The key the
533
+ * confirmer chose is the key that goes out — a capability confirmation is
534
+ * bound to its idempotency key, so minting a fresh one here would invalidate
535
+ * the confirmation.
184
536
  */
185
537
  async create(input, options) {
186
- return this.client.post("/api/v1/channels/connect-links", input, options);
538
+ const resolved = await this.confirmer.prepare(
539
+ { capabilityId: "channel.connect_link.create.execute", body: input },
540
+ void 0,
541
+ options
542
+ );
543
+ return this.client.postOnce("/api/v1/channels/connect-links", input, resolved);
187
544
  }
188
- /** List the workspace's connect links (tokens are never returned). */
545
+ /**
546
+ * List the workspace's connect links (tokens are never returned), newest
547
+ * first, with cursor-based pagination.
548
+ *
549
+ * `limit` defaults to 50 server-side and is capped at 100. Follow
550
+ * `pagination.next_cursor` while `pagination.has_more` is true.
551
+ *
552
+ * The `channel_type` / `status` filters are applied **within** each page,
553
+ * so a page may hold fewer than `limit` items while `has_more` is still
554
+ * true — drive the loop off `has_more`, never off the item count.
555
+ */
189
556
  async list(options) {
190
557
  const params = {};
558
+ if (options?.limit !== void 0) params.limit = String(options.limit);
559
+ if (options?.cursor) params.cursor = options.cursor;
191
560
  if (options?.channel_type) params.channel_type = options.channel_type;
192
561
  if (options?.status) params.status = options.status;
193
562
  return this.client.get("/api/v1/channels/connect-links", params);
194
563
  }
195
564
  /** Revoke a pending connect link so it can no longer be consumed. */
196
565
  async revoke(id, options) {
197
- return this.client.delete(`/api/v1/channels/connect-links/${encodeURIComponent(id)}`, options);
566
+ const resolved = await this.confirmer.prepare(
567
+ { capabilityId: "channel.connect_link.revoke.execute", body: void 0 },
568
+ { id },
569
+ options
570
+ );
571
+ return this.client.delete(`/api/v1/channels/connect-links/${encodeURIComponent(id)}`, resolved);
198
572
  }
199
573
  };
200
574
  var ChannelConnections = class {
201
- constructor(client) {
575
+ constructor(client, confirmer) {
202
576
  this.client = client;
577
+ this.confirmer = confirmer;
203
578
  }
204
579
  client;
205
- /** List the workspace's channel connections (generic, channel-agnostic shape). */
206
- async list() {
207
- return this.client.get("/api/v1/channels/connections");
580
+ confirmer;
581
+ /**
582
+ * List the workspace's channel connections (generic, channel-agnostic
583
+ * shape), newest first, with cursor-based pagination.
584
+ *
585
+ * `limit` defaults to 50 server-side and is capped at 100. Follow
586
+ * `pagination.next_cursor` while `pagination.has_more` is true. Rows that
587
+ * are not projectable as connections are dropped within the page, so a page
588
+ * may hold fewer than `limit` items while `has_more` is still true — drive
589
+ * the loop off `has_more`, never off the item count.
590
+ */
591
+ async list(options) {
592
+ const params = {};
593
+ if (options?.limit !== void 0) params.limit = String(options.limit);
594
+ if (options?.cursor) params.cursor = options.cursor;
595
+ return this.client.get("/api/v1/channels/connections", params);
208
596
  }
209
597
  /**
210
598
  * Disconnect a connected channel account (best-effort platform logout, then
@@ -212,15 +600,21 @@ var ChannelConnections = class {
212
600
  * `reason: "api_disconnect"` if the account was previously connected.
213
601
  */
214
602
  async disconnect(id, options) {
215
- return this.client.delete(`/api/v1/channels/connections/${encodeURIComponent(id)}`, options);
603
+ const resolved = await this.confirmer.prepare(
604
+ { capabilityId: "channel.connection.disconnect.execute", body: void 0 },
605
+ { id },
606
+ options
607
+ );
608
+ return this.client.delete(`/api/v1/channels/connections/${encodeURIComponent(id)}`, resolved);
216
609
  }
217
610
  };
218
611
  var Channels = class {
219
612
  connectLinks;
220
613
  connections;
221
- constructor(client) {
222
- this.connectLinks = new ChannelConnectLinks(client);
223
- this.connections = new ChannelConnections(client);
614
+ constructor(client, confirmer) {
615
+ const resolved = confirmer ?? new CapabilityConfirmer(new CapabilityConfirmations(client));
616
+ this.connectLinks = new ChannelConnectLinks(client, resolved);
617
+ this.connections = new ChannelConnections(client, resolved);
224
618
  }
225
619
  };
226
620
 
@@ -241,9 +635,17 @@ var Contacts = class {
241
635
  if (options?.search) params.search = options.search;
242
636
  return this.client.get("/api/v1/contacts", params);
243
637
  }
244
- /** Create a new contact. Email must be unique in the workspace. */
245
- async create(input) {
246
- return this.client.post("/api/v1/contacts", input);
638
+ /**
639
+ * Create a new contact. Email must be unique in the workspace.
640
+ *
641
+ * Automatically idempotent: the SDK mints an `Idempotency-Key` so its own
642
+ * 5xx retries replay rather than run the create a second time. Uniqueness
643
+ * alone would not save you here — it turns the retry of a committed create
644
+ * into a spurious conflict, which reads as "the contact was not created".
645
+ * Supply `options.idempotencyKey` to deduplicate across your OWN retries too.
646
+ */
647
+ async create(input, options) {
648
+ return this.client.postOnce("/api/v1/contacts", input, options);
247
649
  }
248
650
  /** Get a contact by ID. */
249
651
  async get(id) {
@@ -264,13 +666,26 @@ var Contacts = class {
264
666
  if (options?.cursor) params.cursor = options.cursor;
265
667
  return this.client.get(`/api/v1/contacts/${encodeURIComponent(id)}/activities`, params);
266
668
  }
267
- /** Add a note to a contact's timeline. */
268
- async addNote(id, input) {
269
- return this.client.post(`/api/v1/contacts/${encodeURIComponent(id)}/notes`, input);
669
+ /**
670
+ * Add a note to a contact's timeline.
671
+ *
672
+ * Automatically idempotent: nothing about a note is unique, so an unkeyed
673
+ * retry appends the same text to the timeline twice. Supply
674
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
675
+ */
676
+ async addNote(id, input, options) {
677
+ return this.client.postOnce(`/api/v1/contacts/${encodeURIComponent(id)}/notes`, input, options);
270
678
  }
271
- /** Bulk import contacts (max 500). Duplicates are skipped. */
272
- async import(contacts) {
273
- return this.client.post("/api/v1/contacts/import", { contacts });
679
+ /**
680
+ * Bulk import contacts (max 500). Duplicates are skipped.
681
+ *
682
+ * Automatically idempotent: the import is processed in chunks, so a retry
683
+ * after a partial failure re-walks the whole batch and reports `added` /
684
+ * `skipped` counts for a run that was not the first. Supply
685
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
686
+ */
687
+ async import(contacts, options) {
688
+ return this.client.postOnce("/api/v1/contacts/import", { contacts }, options);
274
689
  }
275
690
  };
276
691
 
@@ -289,9 +704,15 @@ var Deals = class {
289
704
  if (options?.search) params.search = options.search;
290
705
  return this.client.get("/api/v1/deals", params);
291
706
  }
292
- /** Create a new deal. */
293
- async create(input) {
294
- return this.client.post("/api/v1/deals", input);
707
+ /**
708
+ * Create a new deal.
709
+ *
710
+ * Automatically idempotent: nothing about a deal is unique, so an unkeyed
711
+ * retry puts a second identical deal in the pipeline. Supply
712
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
713
+ */
714
+ async create(input, options) {
715
+ return this.client.postOnce("/api/v1/deals", input, options);
295
716
  }
296
717
  /** Get a deal by ID. */
297
718
  async get(id) {
@@ -335,9 +756,18 @@ var Emails = class {
335
756
  /**
336
757
  * Send a transactional email using a template (HTTP 202). The returned `id`
337
758
  * is an email send id — poll `emails.get(id)` with it to track delivery.
759
+ *
760
+ * Automatically idempotent: the SDK mints an `Idempotency-Key` so its own
761
+ * 5xx retries replay rather than queue a second copy into someone's inbox —
762
+ * a send that already committed cannot be un-sent. Supply
763
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
764
+ *
765
+ * `input.idempotency_key` is the older, body-level form of the same control
766
+ * and still takes precedence server-side, so setting it keeps working
767
+ * unchanged.
338
768
  */
339
- async send(input) {
340
- return this.client.post("/api/v1/emails", input);
769
+ async send(input, options) {
770
+ return this.client.postOnce("/api/v1/emails", input, options);
341
771
  }
342
772
  /** Get the delivery status of a sent email. */
343
773
  async get(id) {
@@ -346,9 +776,14 @@ var Emails = class {
346
776
  /**
347
777
  * Send the same template to multiple recipients (max 100, HTTP 202). Each
348
778
  * queued recipient gets its own send id in `results` for `emails.get(id)`.
779
+ *
780
+ * Automatically idempotent — and this is the call where it matters most: an
781
+ * unkeyed retry of a batch that already committed sends up to 100 duplicate
782
+ * emails. Supply `options.idempotencyKey` to deduplicate across your OWN
783
+ * retries too.
349
784
  */
350
- async batch(input) {
351
- return this.client.post("/api/v1/emails/batch", input);
785
+ async batch(input, options) {
786
+ return this.client.postOnce("/api/v1/emails/batch", input, options);
352
787
  }
353
788
  };
354
789
 
@@ -358,9 +793,17 @@ var Gdpr = class {
358
793
  this.client = client;
359
794
  }
360
795
  client;
361
- /** Request a workspace data export. Runs asynchronously. */
362
- async requestExport() {
363
- return this.client.post("/api/v1/gdpr/export");
796
+ /**
797
+ * Request a workspace data export. Runs asynchronously.
798
+ *
799
+ * Automatically idempotent: the request is recorded and the export is
800
+ * scheduled in one step with no de-duplication of its own, so an unkeyed
801
+ * retry files a second subject-access request and runs a second full export
802
+ * of the workspace. Supply `options.idempotencyKey` to deduplicate across
803
+ * your OWN retries too.
804
+ */
805
+ async requestExport(options) {
806
+ return this.client.postOnce("/api/v1/gdpr/export", void 0, options);
364
807
  }
365
808
  /** List all workspace export requests. */
366
809
  async listExports() {
@@ -370,7 +813,13 @@ var Gdpr = class {
370
813
  async getExport(id) {
371
814
  return this.client.get(`/api/v1/gdpr/exports/${encodeURIComponent(id)}`);
372
815
  }
373
- /** Record a GDPR consent decision for a contact by email. */
816
+ /**
817
+ * Record a GDPR consent decision for a contact by email.
818
+ *
819
+ * Deliberately unkeyed: a decision is stored once per
820
+ * (workspace, email, consent type) and overwritten in place, so re-sending
821
+ * the same body reaches the same state and returns the same record id.
822
+ */
374
823
  async recordConsent(input) {
375
824
  return this.client.post("/api/v1/gdpr/consent", input);
376
825
  }
@@ -378,7 +827,15 @@ var Gdpr = class {
378
827
  async getConsent(email) {
379
828
  return this.client.get(`/api/v1/gdpr/consent/${encodeURIComponent(email)}`);
380
829
  }
381
- /** Record cookie consent from an external site (legacy endpoint). */
830
+ /**
831
+ * Record cookie consent from an external site (legacy endpoint).
832
+ *
833
+ * Deliberately unkeyed: this legacy route predates the versioned API and
834
+ * does not run the `Idempotency-Key` machinery, so a key here would be a
835
+ * header that changes nothing while implying a guarantee the endpoint cannot
836
+ * make. Treat a failed call as "unknown" and re-send only if a missing
837
+ * consent log matters more to you than a duplicate one.
838
+ */
382
839
  async cookieConsent(input) {
383
840
  return this.client.post("/api/cookie-consent", input);
384
841
  }
@@ -386,10 +843,12 @@ var Gdpr = class {
386
843
 
387
844
  // src/resources/helpdesk.ts
388
845
  var HelpdeskConversations = class {
389
- constructor(client) {
846
+ constructor(client, confirmer) {
390
847
  this.client = client;
848
+ this.confirmer = confirmer;
391
849
  }
392
850
  client;
851
+ confirmer;
393
852
  /** List/search conversations with cursor-based pagination and optional filters. */
394
853
  async list(options) {
395
854
  const params = {};
@@ -408,10 +867,15 @@ var HelpdeskConversations = class {
408
867
  }
409
868
  /** Update a conversation's status and/or assignee (pass `assignee_user_id: null` to unassign). */
410
869
  async update(id, input, options) {
870
+ const resolved = await this.confirmer.prepare(
871
+ { capabilityId: "helpdesk.conversation.update.execute", body: input },
872
+ { id },
873
+ options
874
+ );
411
875
  return this.client.patch(
412
876
  `/api/v1/helpdesk/conversations/${encodeURIComponent(id)}`,
413
877
  input,
414
- options
878
+ resolved
415
879
  );
416
880
  }
417
881
  /** Read a conversation's messages with cursor-based pagination. */
@@ -426,26 +890,42 @@ var HelpdeskConversations = class {
426
890
  }
427
891
  };
428
892
  var HelpdeskReplies = class {
429
- constructor(client) {
893
+ constructor(client, confirmer) {
430
894
  this.client = client;
895
+ this.confirmer = confirmer;
431
896
  }
432
897
  client;
898
+ confirmer;
433
899
  /**
434
900
  * Send an operator reply or internal note. Returns HTTP 201.
435
901
  *
436
- * Pass an `idempotencyKey` so retried requests do not create duplicate
437
- * messages it is REQUIRED for capability-scoped tokens.
902
+ * Automatically idempotent: a reply is a message to a real person, and an
903
+ * unkeyed retry sends it to them twice. The key the confirmer chose is the
904
+ * key that goes out — a capability confirmation is bound to its idempotency
905
+ * key, so minting a fresh one here would invalidate the confirmation.
906
+ *
907
+ * Pass `options.idempotencyKey` to deduplicate across your OWN retries too.
908
+ * It is REQUIRED for capability-scoped tokens, which need it paired with a
909
+ * `capabilityConfirmation` — a generated key satisfies the pairing's key
910
+ * half only; the confirmation is still yours to supply (or to let
911
+ * `autoConfirm` mint).
438
912
  */
439
913
  async create(input, options) {
440
- return this.client.post("/api/v1/helpdesk/replies", input, options);
914
+ const resolved = await this.confirmer.prepare(
915
+ { capabilityId: "helpdesk.conversation.reply.execute", body: input },
916
+ void 0,
917
+ options
918
+ );
919
+ return this.client.postOnce("/api/v1/helpdesk/replies", input, resolved);
441
920
  }
442
921
  };
443
922
  var Helpdesk = class {
444
923
  conversations;
445
924
  replies;
446
- constructor(client) {
447
- this.conversations = new HelpdeskConversations(client);
448
- this.replies = new HelpdeskReplies(client);
925
+ constructor(client, confirmer) {
926
+ const resolved = confirmer ?? new CapabilityConfirmer(new CapabilityConfirmations(client));
927
+ this.conversations = new HelpdeskConversations(client, resolved);
928
+ this.replies = new HelpdeskReplies(client, resolved);
449
929
  }
450
930
  };
451
931
 
@@ -464,9 +944,15 @@ var Posts = class {
464
944
  if (options?.type) params.type = options.type;
465
945
  return this.client.get("/api/v1/posts", params);
466
946
  }
467
- /** Create a new post with content and target channels. */
468
- async create(input) {
469
- return this.client.post("/api/v1/posts", input);
947
+ /**
948
+ * Create a new post with content and target channels.
949
+ *
950
+ * Automatically idempotent: the SDK mints an `Idempotency-Key` so its own
951
+ * 5xx retries replay rather than draft the post twice. Supply
952
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
953
+ */
954
+ async create(input, options) {
955
+ return this.client.postOnce("/api/v1/posts", input, options);
470
956
  }
471
957
  /** Get a post by ID, including its per-channel variants. */
472
958
  async get(id) {
@@ -480,11 +966,26 @@ var Posts = class {
480
966
  async remove(id) {
481
967
  return this.client.delete(`/api/v1/posts/${encodeURIComponent(id)}`);
482
968
  }
483
- /** Schedule a post for future publication. */
969
+ /**
970
+ * Schedule a post for future publication.
971
+ *
972
+ * Deliberately unkeyed: re-sending the same `scheduled_at` for an
973
+ * already-scheduled post returns the original `workflow_id` rather than
974
+ * starting a second one, so a retried schedule cannot double-publish. A
975
+ * *different* time is rejected — unschedule first.
976
+ */
484
977
  async schedule(id, input) {
485
978
  return this.client.post(`/api/v1/posts/${encodeURIComponent(id)}/schedule`, input);
486
979
  }
487
- /** Publish a post immediately to all target channels. */
980
+ /**
981
+ * Publish a post immediately to all target channels.
982
+ *
983
+ * Deliberately unkeyed: publishing moves the post out of the set of statuses
984
+ * that may be published, so the retry of a publish that already committed is
985
+ * refused rather than posting a second time. It is refused with a 400 though
986
+ * — treat an error here as "check the post's status", not as "nothing
987
+ * happened".
988
+ */
488
989
  async publish(id) {
489
990
  return this.client.post(`/api/v1/posts/${encodeURIComponent(id)}/publish`);
490
991
  }
@@ -494,12 +995,76 @@ var Posts = class {
494
995
  }
495
996
  };
496
997
 
998
+ // src/resources/scan.ts
999
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1000
+ var Scan = class {
1001
+ constructor(client) {
1002
+ this.client = client;
1003
+ }
1004
+ client;
1005
+ /**
1006
+ * Queue a scan. Provide exactly one of `url`, `orgnr`, or `name`.
1007
+ * Runs asynchronously — poll with `get()` or use `waitForResult()`.
1008
+ *
1009
+ * Automatically idempotent: a scan job is queued the moment it is created,
1010
+ * so an unkeyed retry starts a second crawl of the same site and returns an
1011
+ * id for a job that duplicates one already running. Supply
1012
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
1013
+ *
1014
+ * @throws Error before any request when zero or several selectors are set —
1015
+ * the server would reject the body anyway; failing locally is clearer.
1016
+ */
1017
+ async create(input, options) {
1018
+ const entries = ["url", "orgnr", "name"].filter(
1019
+ (key2) => input[key2] !== void 0 && input[key2] !== ""
1020
+ );
1021
+ if (entries.length !== 1) {
1022
+ throw new Error("scan.create requires exactly one of url, orgnr, or name");
1023
+ }
1024
+ const key = entries[0];
1025
+ return this.client.postOnce("/api/v1/scan", { [key]: input[key] }, options);
1026
+ }
1027
+ /** Get a scan job's status and, once done, its findings payload. */
1028
+ async get(id) {
1029
+ return this.client.get(`/api/v1/scan/${encodeURIComponent(id)}`);
1030
+ }
1031
+ /** Search the Norwegian company registry by name (typeahead, top 5 hits). */
1032
+ async companies(q) {
1033
+ return this.client.get("/api/v1/scan/companies", { q });
1034
+ }
1035
+ /**
1036
+ * Poll a scan until it settles. Resolves with the job for both `done` and
1037
+ * `failed` (check `job.error`); throws only when the deadline passes while
1038
+ * the scan is still pending/running.
1039
+ */
1040
+ async waitForResult(id, options = {}) {
1041
+ const rawInterval = options.intervalMs ?? 2500;
1042
+ const rawTimeout = options.timeoutMs ?? 12e4;
1043
+ const intervalMs = Number.isFinite(rawInterval) && rawInterval > 0 ? rawInterval : 2500;
1044
+ const timeoutMs = Number.isFinite(rawTimeout) ? rawTimeout : 12e4;
1045
+ const deadline = Date.now() + timeoutMs;
1046
+ let lastStatus = "pending";
1047
+ for (; ; ) {
1048
+ const { data } = await this.get(id);
1049
+ if (data.status === "done" || data.status === "failed") return data;
1050
+ lastStatus = data.status;
1051
+ const remaining = deadline - Date.now();
1052
+ if (remaining <= 0) break;
1053
+ await sleep(Math.min(intervalMs, remaining));
1054
+ if (Date.now() >= deadline) break;
1055
+ }
1056
+ throw new Error(`Scan ${id} timed out after ${timeoutMs}ms (status: ${lastStatus})`);
1057
+ }
1058
+ };
1059
+
497
1060
  // src/resources/webhooks.ts
498
1061
  var Webhooks = class {
499
- constructor(client) {
1062
+ constructor(client, confirmer) {
500
1063
  this.client = client;
1064
+ this.confirmer = confirmer ?? new CapabilityConfirmer(new CapabilityConfirmations(client));
501
1065
  }
502
1066
  client;
1067
+ confirmer;
503
1068
  /** List all webhook endpoints in the workspace. */
504
1069
  async list() {
505
1070
  return this.client.get("/api/v1/webhooks");
@@ -515,9 +1080,21 @@ var Webhooks = class {
515
1080
  * `secret` is typed optional because an idempotent replay (retrying with the
516
1081
  * same `Idempotency-Key`, `X-Idempotent-Replayed: true`) returns the existing
517
1082
  * endpoint WITHOUT the secret — handle that case (rotate if you lost it).
1083
+ *
1084
+ * Automatically idempotent: a duplicate endpoint is not a stray row, it is a
1085
+ * second copy of every future delivery to the same URL, forever. The key the
1086
+ * confirmer chose is the key that goes out — a capability confirmation is
1087
+ * bound to its idempotency key, so minting a fresh one here would invalidate
1088
+ * the confirmation. That the SDK now always sends a key is also what makes
1089
+ * the replay-without-secret case above reachable on a plain 5xx retry.
518
1090
  */
519
1091
  async create(input, options) {
520
- return this.client.post("/api/v1/webhooks", input, options);
1092
+ const resolved = await this.confirmer.prepare(
1093
+ { capabilityId: "helpdesk.webhook.create.execute", body: input },
1094
+ void 0,
1095
+ options
1096
+ );
1097
+ return this.client.postOnce("/api/v1/webhooks", input, resolved);
521
1098
  }
522
1099
  /** Get a webhook endpoint by ID. */
523
1100
  async get(id) {
@@ -525,7 +1102,12 @@ var Webhooks = class {
525
1102
  }
526
1103
  /** Update a webhook endpoint (name, url, event types, filters, enabled). */
527
1104
  async update(id, input, options) {
528
- return this.client.patch(`/api/v1/webhooks/${encodeURIComponent(id)}`, input, options);
1105
+ const resolved = await this.confirmer.prepare(
1106
+ { capabilityId: "helpdesk.webhook.update.execute", body: input },
1107
+ { id },
1108
+ options
1109
+ );
1110
+ return this.client.patch(`/api/v1/webhooks/${encodeURIComponent(id)}`, input, resolved);
529
1111
  }
530
1112
  /**
531
1113
  * Permanently delete a webhook endpoint (stops all outbound deliveries).
@@ -534,7 +1116,12 @@ var Webhooks = class {
534
1116
  * grants on this route. API keys with legacy scopes may omit it.
535
1117
  */
536
1118
  async delete(id, options) {
537
- return this.client.delete(`/api/v1/webhooks/${encodeURIComponent(id)}`, options);
1119
+ const resolved = await this.confirmer.prepare(
1120
+ { capabilityId: "helpdesk.webhook.delete.execute", body: void 0 },
1121
+ { id },
1122
+ options
1123
+ );
1124
+ return this.client.delete(`/api/v1/webhooks/${encodeURIComponent(id)}`, resolved);
538
1125
  }
539
1126
  /** List recent deliveries for an endpoint (most recent first). */
540
1127
  async deliveries(id, options) {
@@ -542,7 +1129,14 @@ var Webhooks = class {
542
1129
  if (options?.limit !== void 0) params.limit = String(options.limit);
543
1130
  return this.client.get(`/api/v1/webhooks/${encodeURIComponent(id)}/deliveries`, params);
544
1131
  }
545
- /** Queue a signed `test.ping` delivery to the endpoint. Returns HTTP 202. */
1132
+ /**
1133
+ * Queue a signed `test.ping` delivery to the endpoint. Returns HTTP 202.
1134
+ *
1135
+ * Deliberately unkeyed: a duplicate ping is the one duplicate that costs
1136
+ * nothing. Real deliveries are retried too, so any endpoint worth pointing at
1137
+ * already tolerates receiving the same event twice — that is what this call
1138
+ * exists to prove.
1139
+ */
546
1140
  async test(id) {
547
1141
  return this.client.post(`/api/v1/webhooks/${encodeURIComponent(id)}/test`);
548
1142
  }
@@ -632,6 +1226,8 @@ async function verifyWebhookSignature(input) {
632
1226
 
633
1227
  // src/index.ts
634
1228
  var Medal = class {
1229
+ bookings;
1230
+ capabilityConfirmations;
635
1231
  channels;
636
1232
  emails;
637
1233
  contacts;
@@ -639,6 +1235,7 @@ var Medal = class {
639
1235
  gdpr;
640
1236
  helpdesk;
641
1237
  posts;
1238
+ scan;
642
1239
  webhooks;
643
1240
  workspaces;
644
1241
  constructor(token, options) {
@@ -654,14 +1251,21 @@ var Medal = class {
654
1251
  timeout: options?.timeout ?? 3e4,
655
1252
  userAgent: "medalsocial-sdk/1.0.0 (+https://github.com/Medal-Social/MedalSocial)"
656
1253
  });
657
- this.channels = new Channels(client);
1254
+ this.capabilityConfirmations = new CapabilityConfirmations(client);
1255
+ const confirmer = new CapabilityConfirmer(
1256
+ this.capabilityConfirmations,
1257
+ options?.autoConfirmCapabilities
1258
+ );
1259
+ this.bookings = new Bookings(client);
1260
+ this.channels = new Channels(client, confirmer);
658
1261
  this.emails = new Emails(client);
659
1262
  this.contacts = new Contacts(client);
660
1263
  this.deals = new Deals(client);
661
1264
  this.gdpr = new Gdpr(client);
662
- this.helpdesk = new Helpdesk(client);
1265
+ this.helpdesk = new Helpdesk(client, confirmer);
663
1266
  this.posts = new Posts(client);
664
- this.webhooks = new Webhooks(client);
1267
+ this.scan = new Scan(client);
1268
+ this.webhooks = new Webhooks(client, confirmer);
665
1269
  this.workspaces = new Workspaces(client);
666
1270
  }
667
1271
  };
@@ -672,6 +1276,11 @@ var src_default = Medal;
672
1276
  // Annotate the CommonJS export names for ESM import in node:
673
1277
  0 && (module.exports = {
674
1278
  BaseClient,
1279
+ Bookings,
1280
+ CAPABILITY_IDS,
1281
+ CAPABILITY_ROUTES,
1282
+ CapabilityConfirmations,
1283
+ CapabilityConfirmer,
675
1284
  Channels,
676
1285
  Contacts,
677
1286
  DEFAULT_WEBHOOK_TOLERANCE_MS,
@@ -682,6 +1291,7 @@ var src_default = Medal;
682
1291
  Medal,
683
1292
  MedalApiError,
684
1293
  Posts,
1294
+ Scan,
685
1295
  WebhookVerificationError,
686
1296
  Webhooks,
687
1297
  Workspaces,