@awesomate/sdk 0.24.0 → 0.26.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.
Files changed (3) hide show
  1. package/dist/index.d.ts +1329 -62
  2. package/dist/index.js +786 -74
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  /**
2
- * @awesomate/sdk: read a client's own Awesomate data from Node (v0: the contact list).
2
+ * @awesomate/sdk: a client's own Awesomate data from Node and the browser: contacts and app
3
+ * records, saved queries and recipes, the business, bookings, files, tasks, Knowledge and the
4
+ * people who sign in to the account's own apps.
3
5
  *
4
6
  * import { createClient } from '@awesomate/sdk';
5
7
  * import './awesomate'; // the file `npx @awesomate/sdk types` wrote: types every query below
@@ -24,12 +26,19 @@
24
26
  * Docs: https://hub.awesomate.ai/docs/sdk/
25
27
  */
26
28
  /** This package's version, sent to the hub with every server call. */
27
- export const VERSION = '0.24.0';
29
+ export const VERSION = '0.26.0';
28
30
  const DEFAULT_BASE = 'https://hub.awesomate.ai';
29
- const ERROR_CODES = ['unauthenticated', 'forbidden', 'not_found', 'validation', 'consent_blocked', 'rate_limited', 'conflict', 'unavailable'];
31
+ /** Every record and contact id is a UUID; anything else can name no row. */
32
+ const RECORD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
33
+ const ERROR_CODES = [
34
+ 'unauthenticated', 'forbidden', 'not_found', 'validation', 'consent_blocked', 'rate_limited', 'conflict', 'unavailable',
35
+ 'feature_unavailable', 'upgrade_required', 'scope_missing',
36
+ ];
30
37
  /**
31
38
  * Every failure from the hub. `message` is for you, the developer; `personMessage`, when the hub
32
- * sent one, is the sentence to show the person using your app.
39
+ * sent one, is the sentence to show the person using your app. A refusal over a feature, a plan
40
+ * or a token's scopes says what it is about in `reason`, `feature`, `missingScopes`, `upgrade`
41
+ * and `requiredPlan`.
33
42
  */
34
43
  export class AwesomateError extends Error {
35
44
  code;
@@ -37,6 +46,16 @@ export class AwesomateError extends Error {
37
46
  field;
38
47
  serverCode;
39
48
  personMessage;
49
+ /** With feature_unavailable: why the account does not have the feature (not_released, turned_off_by_awesomate, not_on_plan, switched_off_by_you). */
50
+ reason;
51
+ /** With feature_unavailable: the feature's key. */
52
+ feature;
53
+ /** With scope_missing: the scopes the token lacks. Make a token with them in the hub. */
54
+ missingScopes;
55
+ /** The plan that includes it and the hub page to upgrade on, when the hub said. */
56
+ upgrade;
57
+ /** The plan this needs, when the hub named one. */
58
+ requiredPlan;
40
59
  constructor(
41
60
  /** Why it failed. */
42
61
  code, message,
@@ -47,7 +66,9 @@ export class AwesomateError extends Error {
47
66
  /** The hub's own code, unmapped. */
48
67
  serverCode,
49
68
  /** A sentence for the person using the app, when the hub sent one (a refused voice call: "You've used today's voice time"). */
50
- personMessage) {
69
+ personMessage,
70
+ /** The refusal's typed fields. */
71
+ details = {}) {
51
72
  super(message);
52
73
  this.code = code;
53
74
  this.status = status;
@@ -55,10 +76,24 @@ export class AwesomateError extends Error {
55
76
  this.serverCode = serverCode;
56
77
  this.personMessage = personMessage;
57
78
  this.name = 'AwesomateError';
79
+ if (details.reason !== undefined)
80
+ this.reason = details.reason;
81
+ if (details.feature !== undefined)
82
+ this.feature = details.feature;
83
+ if (details.missingScopes !== undefined)
84
+ this.missingScopes = details.missingScopes;
85
+ if (details.upgrade !== undefined)
86
+ this.upgrade = details.upgrade;
87
+ if (details.requiredPlan !== undefined)
88
+ this.requiredPlan = details.requiredPlan;
58
89
  }
59
90
  }
60
- const CONSENT_CODES = new Set(['consent_blocked', 'consent_denied', 'files_consent_off']);
61
- function errorCode(status, server) {
91
+ /** Every code the hub uses for "the account has switched this off in its privacy settings". */
92
+ const CONSENT_CODES = new Set(['consent_blocked', 'consent_denied', 'consent_required', 'consent_off', 'consent_missing', 'files_consent_off']);
93
+ const UPGRADE_CODES = new Set(['upgrade_required', 'plan_required']);
94
+ /** A code-like word: some hub areas send their code in `error` and the sentence in `message`. */
95
+ const CODE_LIKE = /^[a-z][a-z0-9_]*$/;
96
+ function errorCode(status, server, body = {}) {
62
97
  if (server === 'validation' || server === 'invalid' || status === 422)
63
98
  return 'validation';
64
99
  // A file the hub will not take as sent: too big, no length, or a type it refuses.
@@ -66,8 +101,17 @@ function errorCode(status, server) {
66
101
  return 'validation';
67
102
  if (status === 401)
68
103
  return 'unauthenticated';
69
- if (status === 403)
70
- return server && CONSENT_CODES.has(server) ? 'consent_blocked' : 'forbidden';
104
+ if ((server && CONSENT_CODES.has(server)) || body.consent_required === true)
105
+ return 'consent_blocked';
106
+ if (status === 403) {
107
+ if (server === 'feature_unavailable')
108
+ return 'feature_unavailable';
109
+ if (Array.isArray(body.missingScopes))
110
+ return 'scope_missing';
111
+ if ((server && UPGRADE_CODES.has(server)) || Array.isArray(body.requiredPlans) || body.upgrade_required === true)
112
+ return 'upgrade_required';
113
+ return 'forbidden';
114
+ }
71
115
  if (status === 429)
72
116
  return 'rate_limited';
73
117
  if (server === 'conflict')
@@ -76,6 +120,43 @@ function errorCode(status, server) {
76
120
  return 'not_found';
77
121
  return 'unavailable';
78
122
  }
123
+ const nonEmpty = (v) => (typeof v === 'string' && v ? v : undefined);
124
+ /** A refusal's parts from the hub's JSON body, whichever of its shapes the area uses. */
125
+ function refusal(status, json) {
126
+ const error = nonEmpty(json.error);
127
+ const sentence = nonEmpty(json.message);
128
+ // {error: 'invalid_request', message: 'A sentence.'}: the code is in error, the sentence in message.
129
+ const codeInError = !nonEmpty(json.code) && error && sentence && CODE_LIKE.test(error) ? error : undefined;
130
+ const serverCode = nonEmpty(json.code) ?? codeInError;
131
+ const details = {};
132
+ const reason = nonEmpty(json.reason);
133
+ if (reason)
134
+ details.reason = reason;
135
+ const feature = nonEmpty(json.feature);
136
+ if (feature)
137
+ details.feature = feature;
138
+ if (Array.isArray(json.missingScopes))
139
+ details.missingScopes = json.missingScopes.map(String);
140
+ const up = json.upgrade;
141
+ const upPlan = up && typeof up === 'object' ? nonEmpty(up.plan) : undefined;
142
+ if (upPlan)
143
+ details.upgrade = { plan: upPlan, url: nonEmpty(up.url) ?? '/billing' };
144
+ const required = nonEmpty(json.required_plan) ?? nonEmpty(json.requiredPlan) ?? upPlan
145
+ ?? (Array.isArray(json.requiredPlans) ? nonEmpty(json.requiredPlans[0]) : undefined);
146
+ if (required)
147
+ details.requiredPlan = required;
148
+ return {
149
+ code: errorCode(status, serverCode, json),
150
+ message: (codeInError ? sentence : error ?? sentence) ?? `The hub answered ${status}.`,
151
+ serverCode,
152
+ field: nonEmpty(json.field),
153
+ details,
154
+ };
155
+ }
156
+ function hubError(status, json, personMessage) {
157
+ const r = refusal(status, json);
158
+ return new AwesomateError(r.code, r.message, status, r.field, r.serverCode, personMessage, r.details);
159
+ }
79
160
  const ACCOUNT = '/api/my-crm/v1';
80
161
  const KEY_ROUTES = [
81
162
  ['GET', /^\/rows\/schema$/], ['POST', /^\/rows\/query$/], ['GET', /^\/rows\/[^/]+\/[^/?]+(\?.*)?$/], ['POST', /^\/call$/],
@@ -109,13 +190,14 @@ export class AwesomateClient {
109
190
  throw new Error('No fetch available: use Node 18 or later, or pass fetch.');
110
191
  }
111
192
  async request(method, path, body, retry = true) {
112
- if (this.appKey)
113
- path = keyPath(method, path);
114
- const res = await this.doFetch(`${this.base}${path}`, {
193
+ const sent = this.appKey ? keyPath(method, path) : path;
194
+ const res = await this.doFetch(`${this.base}${sent}`, {
115
195
  method,
116
196
  headers: {
117
197
  authorization: `Bearer ${this.opts.token}`,
118
198
  'user-agent': `@awesomate/sdk/${VERSION}`,
199
+ // Knowledge answers stream to a browser; asked for JSON, the hub sends the whole answer.
200
+ accept: 'application/json',
119
201
  ...(body === undefined ? {} : { 'content-type': 'application/json' }),
120
202
  },
121
203
  body: body === undefined ? undefined : JSON.stringify(body),
@@ -128,14 +210,12 @@ export class AwesomateClient {
128
210
  catch { /* a proxy's HTML error page */ }
129
211
  if (res.ok)
130
212
  return json;
131
- const server = typeof json.code === 'string' ? json.code : undefined;
132
- const code = errorCode(res.status, server);
213
+ const err = hubError(res.status, json);
133
214
  // The field list changed while the read ran (the view is rebuilt on every field edit): a read
134
215
  // is safe to run again, once.
135
- if (code === 'conflict' && retry)
216
+ if (err.code === 'conflict' && retry)
136
217
  return this.request(method, path, body, false);
137
- const message = typeof json.error === 'string' ? json.error : `The hub answered ${res.status}.`;
138
- throw new AwesomateError(code, message, res.status, typeof json.field === 'string' ? json.field : undefined, server);
218
+ throw err;
139
219
  }
140
220
  /** A call whose body or answer is a file, not JSON. Answers the Response when it succeeded. */
141
221
  async raw(method, path, body) {
@@ -159,9 +239,7 @@ export class AwesomateClient {
159
239
  json = text ? JSON.parse(text) : {};
160
240
  }
161
241
  catch { /* a proxy's HTML error page */ }
162
- const server = typeof json.code === 'string' ? json.code : undefined;
163
- const message = typeof json.error === 'string' ? json.error : `The hub answered ${res.status}.`;
164
- throw new AwesomateError(errorCode(res.status, server), message, res.status, undefined, server);
242
+ throw hubError(res.status, json);
165
243
  }
166
244
  filesCall(method, path, body) {
167
245
  if (this.appKey) {
@@ -229,6 +307,38 @@ export class AwesomateClient {
229
307
  },
230
308
  /** Move to the trash. Not erased, and no longer counted toward the Files space. */
231
309
  remove: async (path) => this.filesCall('DELETE', `/api/files?path=${encodeURIComponent(path)}`),
310
+ /** What is in the trash, and how many bytes it holds. Needs files:read. */
311
+ trash: async () => {
312
+ const r = await this.filesCall('GET', '/api/files/trash');
313
+ return { entries: r.entries, total_bytes: r.total_bytes };
314
+ },
315
+ /**
316
+ * Erase what is in the trash, for good: the only call that deletes a file outright. With
317
+ * `olderThanDays` (1 to 3650), only what was removed longer ago than that. Needs files:write.
318
+ * An automation account that cannot empty its trash yet answers serverCode trash_empty_unavailable.
319
+ */
320
+ emptyTrash: async (options = {}) => {
321
+ const days = options.olderThanDays;
322
+ if (days !== undefined && (!Number.isInteger(days) || days < 1 || days > 3650)) {
323
+ throw new AwesomateError('validation', 'olderThanDays is a whole number of days from 1 to 3650, or leave it out to empty everything.', 0, 'olderThanDays');
324
+ }
325
+ const r = await this.filesCall('POST', '/api/files/trash/empty', days === undefined ? {} : { older_than_days: days });
326
+ return { removed: r.removed, bytes: r.bytes, files: r.files };
327
+ },
328
+ /**
329
+ * Unpack a .zip in Files into a folder beside it, named after the archive (skills.zip unpacks
330
+ * to skills/). At most 4,000 entries and 250 MB unpacked. Needs files:write.
331
+ */
332
+ extract: async (path) => {
333
+ const r = await this.filesCall('POST', '/api/files/extract', { path });
334
+ return { extractedTo: r.extractedTo, fileCount: r.fileCount };
335
+ },
336
+ /**
337
+ * Whether Files works for this account now, and why not: the plan, the automation account's
338
+ * version, and the owner's privacy switch, with the largest upload and the plan's space. Works
339
+ * with any account token, so check it before offering Files.
340
+ */
341
+ capabilities: () => this.filesCall('GET', '/api/files/capabilities'),
232
342
  };
233
343
  tasksCall(method, path, body) {
234
344
  if (this.appKey) {
@@ -239,40 +349,73 @@ export class AwesomateClient {
239
349
  /**
240
350
  * The account's Tasks: what is waiting on its people, and tasks they give each other. An app's
241
351
  * server or an n8n workflow can give someone a task ("call this customer back") and finish it.
242
- * Needs the account's token, and Tasks on the account (a feature_unavailable refusal says
243
- * when it is not).
352
+ * Needs the account's token (never an app key) and Tasks on the account (a feature_unavailable
353
+ * refusal says when it is not), on every plan. Reading (board, record, decisions) needs
354
+ * hosting:read; every change needs hosting:manage.
244
355
  *
245
356
  * @example
246
357
  * await db.tasks.give({ title: 'Call Sam back about the leak', forEmail: 'jo@brightwater.example', dueAt: '2026-10-06T09:00:00+11:00' });
247
358
  */
248
359
  tasks = {
249
- /** The board: waiting now, put off, with someone else, recently decided. `all` shows everyone's, not only yours. */
360
+ /** The board: waiting now, put off, with someone else, recently decided. `all` shows everyone's, not only yours. hosting:read. */
250
361
  board: (options = {}) => this.tasksCall('GET', `/api/my-tasks${options.scope === 'all' ? '?scope=all' : ''}`),
251
- /** Give a task. The person gets it on their board, and an email when they are not the one giving it. */
362
+ /**
363
+ * The decisions on the Tasks page: waiting now, put off, said yes to and gone further up, and
364
+ * decided in the last 14 days. A token is no one person, so `mine` shows only decisions waiting
365
+ * on the owners; pass `scope: 'all'` for the rest, including what was decided. Read only: deciding is a
366
+ * person's tap in the hub, never a token's, so every card's `actions` is empty here.
367
+ * hosting:read; refused with serverCode `not_live` where decisions are not switched on yet.
368
+ */
369
+ decisions: (options = {}) => this.tasksCall('GET', `/api/my-tasks/decisions${options.scope === 'all' ? '?scope=all' : ''}`),
370
+ /**
371
+ * What happened to a decision, in order, with how long each person had it. A decision this
372
+ * token may not see reads as not found (serverCode `gone`). hosting:read.
373
+ */
374
+ decisionRecord: (taskId) => this.tasksCall('GET', `/api/my-tasks/decisions/${encodeURIComponent(String(taskId))}/record`),
375
+ /** Give a task. The person gets it on their board, and an email when they are not the one giving it. hosting:manage. */
252
376
  give: async (task) => {
253
377
  const r = await this.tasksCall('POST', '/api/my-tasks/tasks', task);
254
378
  return { key: r.key, id: r.id, holder: r.holder, emailed: r.emailed };
255
379
  },
256
- /** Change a given task: whoever wrote it, or the owner. */
380
+ /** Change a given task: whoever wrote it, or the owner. hosting:manage. */
257
381
  update: async (id, patch) => {
258
382
  await this.tasksCall('PATCH', `/api/my-tasks/tasks/${encodeURIComponent(String(id))}`, patch);
259
383
  },
260
- /** Mark a given task done: the person who has it, whoever wrote it, or the owner. */
384
+ /** Mark a given task done: the person who has it, whoever wrote it, or the owner. hosting:manage. */
261
385
  done: async (id) => {
262
386
  await this.tasksCall('POST', `/api/my-tasks/tasks/${encodeURIComponent(String(id))}/done`);
263
387
  },
264
- /** Take a given task off the board without doing it: whoever wrote it, or the owner. */
388
+ /** Take a given task off the board without doing it: whoever wrote it, or the owner. hosting:manage. */
265
389
  drop: async (id) => {
266
390
  await this.tasksCall('POST', `/api/my-tasks/tasks/${encodeURIComponent(String(id))}/drop`);
267
391
  },
268
- /** Put a card off until a time (at most 90 days), for this person only. */
392
+ /**
393
+ * Open a done or dropped task again, with the person who had it. They get an email unless
394
+ * they are the one bringing it back. Who may: see `decided[].canBringBack` on the board.
395
+ * hosting:manage.
396
+ */
397
+ bringBack: async (id) => {
398
+ const r = await this.tasksCall('POST', `/api/my-tasks/tasks/${encodeURIComponent(String(id))}/reopen`);
399
+ return { emailed: r.emailed };
400
+ },
401
+ /**
402
+ * What happened to a given task, in order: who gave it, who passed it on, who closed it and
403
+ * brought it back, and how long each person had it. The owner reads any task's record; anyone
404
+ * else only one they were part of. hosting:read.
405
+ */
406
+ record: (id) => this.tasksCall('GET', `/api/my-tasks/tasks/${encodeURIComponent(String(id))}/record`),
407
+ /** Put a card off until a time (at most 90 days), for this person only. hosting:manage. */
269
408
  notNow: async (key, until) => {
270
409
  const r = await this.tasksCall('POST', `/api/my-tasks/items/${encodeURIComponent(key)}/not-now`, {
271
410
  until: until instanceof Date ? until.toISOString() : until,
272
411
  });
273
412
  return { until: r.until };
274
413
  },
275
- /** Hand a card to someone on the account who can act on it. */
414
+ /** Bring a card put off with notNow() back now, for this person. hosting:manage. */
415
+ cancelNotNow: async (key) => {
416
+ await this.tasksCall('DELETE', `/api/my-tasks/items/${encodeURIComponent(key)}/not-now`);
417
+ },
418
+ /** Hand a card to someone on the account who can act on it. hosting:manage. */
276
419
  passOn: async (key, toEmail, reason) => {
277
420
  const r = await this.tasksCall('POST', `/api/my-tasks/items/${encodeURIComponent(key)}/pass-on`, {
278
421
  toEmail, ...(reason ? { reason } : {}),
@@ -280,16 +423,326 @@ export class AwesomateClient {
280
423
  return { holder: r.holder, emailed: r.emailed };
281
424
  },
282
425
  };
283
- /** The kinds this token can read, their columns, operators and examples. */
426
+ /**
427
+ * The account's bookings, for the business's own staff screens: the list with each customer,
428
+ * one booking, the open times, and booking, cancelling, moving and recording an outcome on
429
+ * anyone's behalf. Needs the account's token (never an app key) and Bookings on the account.
430
+ * Reading needs crm:read, on every plan; changing needs crm:write, Support Plus and above.
431
+ * Booking, moving and cancelling email the customer and the calendar's notice address, unless
432
+ * `notify: false` (say a booking taken by phone that you confirm yourself). For visitors who are
433
+ * not signed in, use createBookingsClient() instead.
434
+ *
435
+ * @example
436
+ * const today = await db.bookings.list({ from: '2026-10-08T00:00:00+11:00', to: '2026-10-09T00:00:00+11:00', status: 'confirmed' });
437
+ */
438
+ bookings = {
439
+ /**
440
+ * How bookings are set up: whether they are on, the calendars (people, rooms) with their
441
+ * hours, the services with the calendars that take them, and this month's online bookings
442
+ * against the plan's. The `key`s here are what openTimes() and book() take. crm:read.
443
+ */
444
+ setup: () => this.request('GET', '/api/my-crm/v1/bookings/setup', undefined, false),
445
+ /**
446
+ * Bookings that start in a window, soonest first, each with its customer. The window is from a
447
+ * day ago for 31 days unless told otherwise; `limit` is 1 to 500 (default 200).
448
+ */
449
+ list: async (options = {}) => {
450
+ const q = new URLSearchParams();
451
+ const from = iso(options.from);
452
+ const to = iso(options.to);
453
+ if (from)
454
+ q.set('from', from);
455
+ if (to)
456
+ q.set('to', to);
457
+ if (options.status)
458
+ q.set('status', options.status);
459
+ if (options.calendar)
460
+ q.set('calendar', options.calendar);
461
+ if (options.limit)
462
+ q.set('limit', String(options.limit));
463
+ const qs = q.toString();
464
+ const r = await this.request('GET', `/api/my-crm/v1/bookings${qs ? `?${qs}` : ''}`, undefined, false);
465
+ return r.bookings.map(staffBooking);
466
+ },
467
+ /** One booking by its id, or null when there is none (an id that is not a booking id never reaches the hub). */
468
+ get: async (id) => {
469
+ if (!RECORD_ID.test(String(id)))
470
+ return null;
471
+ try {
472
+ const r = await this.request('GET', `/api/my-crm/v1/bookings/${encodeURIComponent(String(id))}`, undefined, false);
473
+ return staffBooking(r.booking);
474
+ }
475
+ catch (err) {
476
+ if (err instanceof AwesomateError && err.code === 'not_found')
477
+ return null;
478
+ throw err;
479
+ }
480
+ },
481
+ /**
482
+ * The times a service can be booked, from now for two weeks unless told otherwise (at most 62
483
+ * days at once). The same open times customers see; book() with `outsideHours` goes beyond them.
484
+ */
485
+ openTimes: async (service, options = {}) => {
486
+ const q = new URLSearchParams({ service });
487
+ const from = iso(options.from);
488
+ const to = iso(options.to);
489
+ if (from)
490
+ q.set('from', from);
491
+ if (to)
492
+ q.set('to', to);
493
+ if (options.calendar)
494
+ q.set('calendar', options.calendar);
495
+ if (options.seats)
496
+ q.set('seats', String(options.seats));
497
+ return (await this.request('GET', `/api/my-crm/v1/bookings/open-times?${q}`, undefined, false)).times;
498
+ },
499
+ /**
500
+ * Book a time for a customer named by email (found in Contacts, or added; consent is never
501
+ * changed). Switches Bookings on if it was not. A time that is not open is refused with code
502
+ * `conflict` and `field` saying why: not_open, slot_taken, too_many_seats, session_full or
503
+ * day_full. Pass `idempotencyKey` so a retry returns the same booking.
504
+ */
505
+ book: async (request) => {
506
+ const r = await this.request('POST', '/api/my-crm/v1/bookings', {
507
+ service: request.service, calendar: request.calendar, starts_at: request.startsAt, email: request.email,
508
+ first_name: request.firstName, last_name: request.lastName, phone: request.phone, seats: request.seats, answers: request.answers,
509
+ idempotency_key: request.idempotencyKey, outside_hours: request.outsideHours, notify: request.notify,
510
+ }, false);
511
+ return { bookingId: r.booking_id, created: r.created, startsAt: r.starts_at, endsAt: r.ends_at };
512
+ },
513
+ /** Cancel a booking. A booking already cancelled answers `cancelled: false`. */
514
+ cancel: async (id, options = {}) => {
515
+ const r = await this.request('POST', `/api/my-crm/v1/bookings/${encodeURIComponent(String(id))}/cancel`, { reason: options.reason, notify: options.notify }, false);
516
+ return { bookingId: r.booking_id, cancelled: r.cancelled, status: r.status };
517
+ },
518
+ /**
519
+ * Move a confirmed booking to a new start, on the same calendar or another (`calendar`). A time
520
+ * that is not open is refused with code `conflict` (field not_open, slot_taken, session_full or
521
+ * day_full) unless `outsideHours`; a booking that is not confirmed, or a time that has passed,
522
+ * with code `validation`.
523
+ */
524
+ move: async (id, startsAt, options = {}) => {
525
+ const r = await this.request('POST', `/api/my-crm/v1/bookings/${encodeURIComponent(String(id))}/move`, { starts_at: iso(startsAt), calendar: options.calendar, outside_hours: options.outsideHours, notify: options.notify }, false);
526
+ return { bookingId: r.booking_id, startsAt: r.starts_at, endsAt: r.ends_at, calendar: r.calendar };
527
+ },
528
+ /** Record how a booking went: the customer came (`completed`) or did not (`no_show`). Sends no email. */
529
+ outcome: async (id, status) => {
530
+ await this.request('POST', `/api/my-crm/v1/bookings/${encodeURIComponent(String(id))}/outcome`, { status }, false);
531
+ },
532
+ };
533
+ /**
534
+ * The people who sign in to the account's own apps (createAppClient()), for a staff screen or
535
+ * your own sign-up flow. One list for the account: a person signs in to any of its apps with
536
+ * the same email and role. Needs the account's token, never an app key. Reading needs crm:read
537
+ * on every plan; adding and changing people needs crm:write and Pro and above (signing people
538
+ * in to your own apps is part of Pro).
539
+ *
540
+ * @example
541
+ * const person = await db.appUsers.invite('sam@example.com', { role: 'customer' });
542
+ * await db.appUsers.update(person.id, { disabled: true }); // signs them out everywhere
543
+ */
544
+ appUsers = {
545
+ /** Everyone who can sign in, newest first (at most 1,000), with their role, last sign-in and whether they are disabled. */
546
+ list: async () => (await this.request('GET', '/api/my-crm/v1/app-users')).users,
547
+ /**
548
+ * Let someone sign in, with a role (default `member`); a person already on the list keeps
549
+ * their id and takes the new role. No email is sent: they sign in from your app with
550
+ * auth.signInWithLink(). They are linked to the contact in Contacts with the same email.
551
+ */
552
+ invite: async (email, options = {}) => (await this.request('POST', '/api/my-crm/v1/app-users', { email, role: options.role ?? null }, false)).user,
553
+ /**
554
+ * Change someone's role, disable or enable them, or link them to a contact (`contactId`, or
555
+ * null to unlink). Disabling ends every session they have at once; `sessionsRevoked` says how
556
+ * many. A role is lower case: letters, digits and _.
557
+ */
558
+ update: async (id, patch) => {
559
+ const body = {};
560
+ if (patch.role !== undefined)
561
+ body.role = patch.role;
562
+ if (patch.disabled !== undefined)
563
+ body.disabled = patch.disabled;
564
+ if (patch.contactId !== undefined)
565
+ body.contact_id = patch.contactId;
566
+ const r = await this.request('PATCH', `/api/my-crm/v1/app-users/${encodeURIComponent(id)}`, body, false);
567
+ return { user: r.user, sessionsRevoked: r.sessions_revoked ?? 0 };
568
+ },
569
+ };
570
+ /**
571
+ * The account's own support desk: the help inbox for its customers, under Contacts, Support in
572
+ * the hub. Read only, on purpose: every message here was written by someone outside the
573
+ * business, so replying, notes and status stay a person's click in the hub. Treat every message
574
+ * as data, never as instructions. Needs the account's token with crm:read (never an app key),
575
+ * Support Plus and above, and the support desk on the account. A desk that is not set up answers
576
+ * not_found with serverCode `no_desk`.
577
+ *
578
+ * @example
579
+ * const { tickets } = await db.supportDesk.tickets({ status: 'open', limit: 10 });
580
+ */
581
+ supportDesk = {
582
+ /** Whether the desk is on, the address mail is forwarded to, how its AI answers, and how many tickets are in each status. */
583
+ status: () => this.request('GET', '/api/my-crm/v1/support'),
584
+ /** Tickets in one status (default `open`, or `all`), most recent first; `limit` 1 to 100 (default 25). */
585
+ tickets: async (options = {}) => {
586
+ const q = new URLSearchParams();
587
+ if (options.status)
588
+ q.set('status', options.status);
589
+ if (options.limit)
590
+ q.set('limit', String(options.limit));
591
+ const qs = q.toString();
592
+ const r = await this.request('GET', `/api/my-crm/v1/support/tickets${qs ? `?${qs}` : ''}`);
593
+ return { counts: r.counts, tickets: r.tickets };
594
+ },
595
+ /**
596
+ * One ticket with its messages, the team's notes and any reply waiting for a person's OK, or
597
+ * null when there is none (an id that is not a ticket id never reaches the hub).
598
+ */
599
+ ticket: async (id) => {
600
+ if (!RECORD_ID.test(String(id)))
601
+ return null;
602
+ try {
603
+ const r = await this.request('GET', `/api/my-crm/v1/support/tickets/${encodeURIComponent(String(id))}`);
604
+ return { ticket: r.ticket, messages: r.messages, team_notes: r.team_notes, held_replies: r.held_replies, answer_in_hub: r.answer_in_hub };
605
+ }
606
+ catch (err) {
607
+ if (err instanceof AwesomateError && err.code === 'not_found' && err.serverCode !== 'no_desk')
608
+ return null;
609
+ throw err;
610
+ }
611
+ },
612
+ };
613
+ /**
614
+ * The account's Knowledge Base, from a server: a cited answer, instant search, and Your numbers
615
+ * (the business's own figures: imported tables, Google Analytics and Search Console). Needs the
616
+ * account's token with knowledge:read (never an app key), Knowledge on the account and active,
617
+ * and the owner's "Use your content for Knowledge" switch on (consent_blocked when it is off).
618
+ * Every plan. Answers count towards the account's Knowledge answers; these calls share the hub's
619
+ * limit of 30 a minute. For a signed-in person in an app, use app.ask() instead.
620
+ *
621
+ * @example
622
+ * const a = await db.knowledge.ask('What is our refund policy?');
623
+ * if (a.grounded) console.log(a.answer, a.sources);
624
+ */
625
+ knowledge = {
626
+ /**
627
+ * A cited answer from the account's own content. `grounded` is true only when the answer came
628
+ * with sources: an answer with none came from the model, not the content, so never present it
629
+ * as the business's. `session` keeps a conversation going.
630
+ */
631
+ ask: async (question, options = {}) => {
632
+ const r = await this.request('POST', '/api/knowledge/chat', {
633
+ question, ...(options.session ? { session: options.session } : {}), ...(options.filters ? { filters: options.filters } : {}),
634
+ }, false);
635
+ const status = typeof r.status === 'string' ? r.status : 'error';
636
+ const sources = (Array.isArray(r.sources) ? r.sources : []).map((s, i) => ({
637
+ ref: typeof s.ref === 'number' ? s.ref : i + 1,
638
+ title: typeof s.title === 'string' ? s.title : null,
639
+ kind: typeof s.kind === 'string' ? s.kind : null,
640
+ locator: (typeof s.locator === 'string' ? s.locator : typeof s.section_path === 'string' ? s.section_path : null),
641
+ url: typeof s.url === 'string' ? s.url : null,
642
+ }));
643
+ const text = typeof r.answer_plain === 'string' && r.answer_plain ? r.answer_plain : typeof r.answer === 'string' ? r.answer : null;
644
+ return {
645
+ status,
646
+ answer: status === 'ok' ? text : null,
647
+ grounded: status === 'ok' && sources.length > 0,
648
+ sources,
649
+ noAnswerMessage: typeof r.no_answer_message === 'string' ? r.no_answer_message : null,
650
+ session: typeof r.session === 'string' ? r.session : null,
651
+ };
652
+ },
653
+ /**
654
+ * Instant search over the library: hits with highlighted snippets and facet counts for the
655
+ * current filters. A facet given several values matches any of them. Snippets mark the
656
+ * matched words between `highlight.pre` and `highlight.post`. `limit` is 1 to 50.
657
+ */
658
+ search: (options = {}) => {
659
+ const q = new URLSearchParams();
660
+ const add = (name, v) => {
661
+ if (v === undefined || v === '')
662
+ return;
663
+ for (const item of Array.isArray(v) ? v : [v])
664
+ q.append(name, String(item));
665
+ };
666
+ add('q', options.q);
667
+ add('kind', options.kind);
668
+ add('category', options.category);
669
+ add('person', options.person);
670
+ add('place', options.place);
671
+ add('topic', options.topic);
672
+ add('author', options.author);
673
+ add('doc', options.doc);
674
+ add('year', options.year);
675
+ add('limit', options.limit);
676
+ add('offset', options.offset);
677
+ add('sort', options.sort);
678
+ if (options.includeMedia)
679
+ q.set('include', 'media');
680
+ const qs = q.toString();
681
+ return this.request('GET', `/api/knowledge/explore${qs ? `?${qs}` : ''}`);
682
+ },
683
+ /**
684
+ * Grouped numbers from Your numbers: a dataset by its id (the Data tab lists them; built-in
685
+ * ones are `ds-builtin-<slug>`, such as `ds-builtin-google-analytics-daily`), one to eight
686
+ * measures, and optional dimensions, filters and time. The answer is the platform's own shape:
687
+ * columns and rows, plus what it assumed.
688
+ */
689
+ data: (query) => this.request('POST', '/api/knowledge/data/query', query, false),
690
+ };
691
+ /**
692
+ * Tell Contacts something happened to someone ("job_paid", "quote_sent"), so any email series
693
+ * waiting for that event starts or stops for them. The person is found by email, or added; it
694
+ * never signs anyone up for email or changes their consent. Sending the same `key` twice records
695
+ * the event once (`duplicate: true`), so pass your own id for it. `record` names the quote, job or
696
+ * booking it is about. An event more than two days old is kept as a fact a series reads, never
697
+ * a start. Needs the account's token with crm:write (Support Plus and above), never an app key.
698
+ * The answer is the same whether or not the address belongs to someone erased from Contacts.
699
+ *
700
+ * @example
701
+ * await db.recordEvent({ event: 'job_paid', email: 'sam@example.com', record: 'job-1042', key: 'xero:INV-1042' });
702
+ */
703
+ async recordEvent(event) {
704
+ const r = await this.request('POST', '/api/my-crm/v1/events', {
705
+ event: event.event, email: event.email, record: event.record, occurred_at: iso(event.occurredAt), key: event.key,
706
+ first_name: event.firstName, last_name: event.lastName,
707
+ }, false);
708
+ return { duplicate: !!r.duplicate };
709
+ }
710
+ /**
711
+ * Who this token is: the account, its plan, the token's scopes and when it expires, and which
712
+ * features the account has (each with the sentence to say when it does not). An account token
713
+ * acts as the account's owner, whoever made it. Needs hosting:read; an app key cannot ask (it
714
+ * reads and writes its own kinds only).
715
+ *
716
+ * @example
717
+ * const me = await db.whoami();
718
+ * if (!me.features?.bookings?.available) console.log(me.features?.bookings?.message);
719
+ */
720
+ async whoami() {
721
+ const r = await this.request('GET', '/api/hosting-access/context');
722
+ return {
723
+ account: { contactId: r.contactId, slug: r.slug ?? null, email: r.email ?? null },
724
+ plan: r.plan ?? null,
725
+ scopes: Array.isArray(r.scopes) ? r.scopes.map(String) : [],
726
+ tokenExpiresAt: r.patExpiresAt ?? null,
727
+ features: r.features ?? null,
728
+ };
729
+ }
730
+ /**
731
+ * The kinds this token can read: each kind's columns with their types, operators, choices and
732
+ * the owner's descriptions and sections, how many fields are hidden and why, the query grammar
733
+ * and examples. Works with the account's token (crm:read, every plan, Contacts on the account)
734
+ * and with an app key (its own kinds, and `access` says whether it can write).
735
+ */
284
736
  schema() {
285
737
  return this.request('GET', '/api/my-crm/v1/rows/schema');
286
738
  }
287
- /** The readable kinds as a TypeScript file (what the types command writes). */
739
+ /** The readable kinds as a TypeScript file (what the types command writes). The account's token, crm:read. */
288
740
  types() {
289
741
  return this.request('GET', '/api/my-crm/v1/rows/types');
290
742
  }
291
743
  /**
292
- * Rows of one kind that this token can read, a page at a time.
744
+ * Rows of one kind that this token can read, a page at a time. The account's token (crm:read,
745
+ * every plan, Contacts on the account) or an app key, for the kinds it was made for.
293
746
  *
294
747
  * @example
295
748
  * const { rows, next } = await db.query('contact', {
@@ -302,7 +755,7 @@ export class AwesomateClient {
302
755
  const r = await this.request('POST', '/api/my-crm/v1/rows/query', { kind, ...options });
303
756
  return { rows: r.rows, next: r.next, count: r.count, applied: r.applied, hidden: r.hidden, asAt: r.as_at };
304
757
  }
305
- /** Every matching row, a page at a time. maxRows guards against reading a whole list by accident. */
758
+ /** Every matching row, a page at a time. maxRows guards against reading a whole list by accident. Needs what query() needs. */
306
759
  async *queryAll(kind, options = {}) {
307
760
  const { maxRows = 10_000, ...rest } = options;
308
761
  let after;
@@ -317,8 +770,13 @@ export class AwesomateClient {
317
770
  after = page.next ?? undefined;
318
771
  } while (after);
319
772
  }
320
- /** One row by id, or null when there is none this token can read. */
773
+ /**
774
+ * One row by id, or null when there is none this token can read. Ids are UUIDs: anything else
775
+ * names no row, so it answers null without asking the hub. Needs what query() needs.
776
+ */
321
777
  async get(kind, id, options = {}) {
778
+ if (!RECORD_ID.test(String(id)))
779
+ return null;
322
780
  try {
323
781
  const qs = options.tz ? `?tz=${encodeURIComponent(options.tz)}` : '';
324
782
  const r = await this.request('GET', `/api/my-crm/v1/rows/${encodeURIComponent(kind)}/${encodeURIComponent(String(id))}${qs}`);
@@ -330,64 +788,88 @@ export class AwesomateClient {
330
788
  throw err;
331
789
  }
332
790
  }
333
- /** The app kinds this account has defined. */
791
+ /** The app kinds this account has defined. The account's token, crm:read, every plan; never an app key. */
334
792
  async kinds() {
335
793
  return (await this.request('GET', '/api/my-crm/v1/kinds')).kinds;
336
794
  }
337
- /** Define an app kind (a table an app keeps). No migration: registry rows and generated views. */
795
+ /**
796
+ * Define an app kind (a table an app keeps). No migration: registry rows and generated views.
797
+ * The account's token, crm:write, Support Plus and above; never an app key.
798
+ */
338
799
  async defineKind(spec) {
339
800
  return (await this.request('POST', '/api/my-crm/v1/kinds', spec)).kind;
340
801
  }
341
- /** Create a record (returns its id), or change one with options.id. Every value is checked against the kind. */
802
+ /**
803
+ * Add an attribute to a kind you defined; records already there have no value for it. Answers
804
+ * the kind as it now is. The account's token, crm:write, Support Plus and above.
805
+ */
806
+ async addAttribute(kind, attribute) {
807
+ return (await this.request('POST', `/api/my-crm/v1/kinds/${encodeURIComponent(kind)}/attributes`, { attribute }, false)).kind;
808
+ }
809
+ /**
810
+ * Archive an attribute: it leaves every read and the generated types, and its values are kept.
811
+ * Answers the kind as it now is. The account's token, crm:write, Support Plus and above.
812
+ */
813
+ async archiveAttribute(kind, key) {
814
+ return (await this.request('DELETE', `/api/my-crm/v1/kinds/${encodeURIComponent(kind)}/attributes/${encodeURIComponent(key)}`, undefined, false)).kind;
815
+ }
816
+ /**
817
+ * Create a record (returns its id), or change one with options.id. Every value is checked
818
+ * against the kind. crm:write, Support Plus and above: the account's token, or an app key made
819
+ * with write access for this kind.
820
+ */
342
821
  async write(kind, data, options = {}) {
343
822
  const r = await this.request('POST', '/api/my-crm/v1/call', {
344
823
  fn: 'write_record', args: { kind, data, links: options.links ?? {}, ...(options.id ? { id: options.id } : {}) },
345
824
  }, false);
346
825
  return r.result.id;
347
826
  }
348
- /** Archive a record: it leaves every read and is kept for a restore. */
827
+ /** Archive a record: it leaves every read and is kept for a restore. Needs what write() needs. */
349
828
  async archive(kind, id) {
350
829
  await this.request('POST', '/api/my-crm/v1/call', { fn: 'archive_record', args: { kind, id } }, false);
351
830
  }
352
- /** The saved queries on this account. */
831
+ /** The saved queries on this account. crm:read, every plan: the account's token or an app key. */
353
832
  async queries() {
354
833
  return (await this.request('GET', '/api/my-crm/v1/queries')).queries;
355
834
  }
356
835
  /**
357
836
  * Save a query by name (saving a name again replaces it). The spec is the query() grammar on one
358
837
  * kind, with {"$param": "<name>"} where a caller's value goes; it is compiled before it is
359
- * stored, so a column it cannot read is refused now rather than when it runs.
838
+ * stored, so a column it cannot read is refused now rather than when it runs. The account's
839
+ * token, crm:write, Support Plus and above; never an app key.
360
840
  */
361
841
  async saveQuery(spec) {
362
842
  return (await this.request('POST', '/api/my-crm/v1/queries', spec, false)).query;
363
843
  }
364
- /** Archive a saved query by its key. */
844
+ /** Archive a saved query by its key. The account's token, crm:write, Support Plus and above. */
365
845
  async archiveQuery(key) {
366
846
  await this.request('DELETE', `/api/my-crm/v1/queries/${encodeURIComponent(key)}`, undefined, false);
367
847
  }
368
- /** Run a saved query with its params. An optional param left out drops its condition. */
848
+ /** Run a saved query with its params. An optional param left out drops its condition. crm:read, every plan: the account's token or an app key. */
369
849
  async run(query, params = {}, options = {}) {
370
850
  const r = await this.request('POST', `/api/my-crm/v1/queries/${encodeURIComponent(query)}/run`, { params, ...options });
371
851
  return { rows: r.rows, next: r.next, count: r.count, applied: r.applied, hidden: r.hidden, asAt: r.as_at };
372
852
  }
373
- /** The write recipes on this account. */
853
+ /** The write recipes on this account. crm:read, every plan: the account's token or an app key. */
374
854
  async recipes() {
375
855
  return (await this.request('GET', '/api/my-crm/v1/recipes')).recipes;
376
856
  }
377
857
  /**
378
858
  * Save a write recipe: up to 10 write_record / archive_record steps run in one transaction.
379
859
  * {"$param": "<name>"} takes a caller's value, {"$step": "<as>"} the id an earlier step wrote.
860
+ * The account's token, crm:write, Support Plus and above; never an app key.
380
861
  */
381
862
  async saveRecipe(spec) {
382
863
  return (await this.request('POST', '/api/my-crm/v1/recipes', spec, false)).recipe;
383
864
  }
384
- /** Archive a write recipe by its key. */
865
+ /** Archive a write recipe by its key. The account's token, crm:write, Support Plus and above. */
385
866
  async archiveRecipe(key) {
386
867
  await this.request('DELETE', `/api/my-crm/v1/recipes/${encodeURIComponent(key)}`, undefined, false);
387
868
  }
388
869
  /**
389
870
  * Run a write recipe. Every step commits or none does; a refusal names the step and the field.
390
- * Never retried: a write that may have landed is not safe to send twice.
871
+ * Never retried: a write that may have landed is not safe to send twice. crm:write, Support
872
+ * Plus and above: the account's token, or an app key with write access to every kind it writes.
391
873
  */
392
874
  async call(recipe, args = {}) {
393
875
  return (await this.request('POST', '/api/my-crm/v1/call', { fn: recipe, args }, false)).result;
@@ -411,34 +893,125 @@ export class AwesomateClient {
411
893
  async businessMap() {
412
894
  return (await this.request('GET', '/api/my-business/v2/map?format=json')).map;
413
895
  }
414
- /** Every role on the map, one line each: slug, title, department and who holds it. */
896
+ /** Every role on the map, one line each: slug, title, department and who holds it. hosting:read, the business map on the account. */
415
897
  async businessMapRoles() {
416
898
  return (await this.request('GET', '/api/my-business/v2/map/roles')).roles;
417
899
  }
418
900
  /**
419
901
  * One role by its slug, with the line each helper's instructions carry (`helpers[].instructionLine`):
420
- * which role it helps, for whom, how far it may go and where its procedures are.
902
+ * which role it helps, for whom, how far it may go and where its procedures are. hosting:read,
903
+ * the business map on the account.
421
904
  */
422
905
  async businessMapRole(slug) {
423
906
  return (await this.request('GET', `/api/my-business/v2/map/roles/${encodeURIComponent(slug)}`)).role;
424
907
  }
425
908
  /**
426
909
  * The business map in its first shape, where a department is a `division`, a sub-department a
427
- * `department` and a role a `job`.
910
+ * `department` and a role a `job`. hosting:read, the business map on the account.
428
911
  * @deprecated Use businessMap(), which uses the words the owner sees. v1 is removed once it has
429
912
  * gone 30 days unused, and not before 2026-11-06.
430
913
  */
431
914
  async businessMapV1() {
432
915
  return (await this.request('GET', '/api/my-business/v1/map?format=json')).map;
433
916
  }
434
- /** Details waiting for the owner's yes on Your business: from the website, the account, a team member or a file. */
917
+ /**
918
+ * The business's long documents that exist (brand guide, voice guide, brand from the website,
919
+ * business summary): kind, version, length and where each came from, without the text. Needs
920
+ * the account's token (hosting:read, every plan), never an app key.
921
+ */
922
+ async businessDocuments() {
923
+ return (await this.request('GET', '/api/my-business/v1/documents')).documents;
924
+ }
925
+ /**
926
+ * One of the business's documents with its text (the current version), or null when the
927
+ * business has none of that kind yet. The text is the owner's own: use it for their business
928
+ * (agent instructions, site copy in their voice), never show it to anyone else. The account's
929
+ * token, hosting:read, every plan.
930
+ */
931
+ async businessDocument(kind) {
932
+ try {
933
+ return await this.request('GET', `/api/my-business/v1/documents/${encodeURIComponent(kind)}`);
934
+ }
935
+ catch (err) {
936
+ if (err instanceof AwesomateError && err.code === 'not_found')
937
+ return null;
938
+ throw err;
939
+ }
940
+ }
941
+ /** Details waiting for the owner's yes on Your business: from the website, the account, a team member or a file. The account's token, hosting:read, every plan. */
435
942
  async businessSuggestions() {
436
943
  return (await this.request('GET', '/api/my-business/v1/proposals')).proposals;
437
944
  }
438
- /** Grouped numbers (counts, sums) in the Business Data API's shape. */
945
+ /**
946
+ * Every detail a business can have (key, label, group, type and longest value), set or not, for
947
+ * a page that offers the missing ones. No values: business() has those. The account's token,
948
+ * hosting:read, every plan.
949
+ */
950
+ businessCatalogue() {
951
+ return this.request('GET', '/api/my-business/v1/catalogue');
952
+ }
953
+ /**
954
+ * Grouped numbers (counts, sums) over the contact list, in the Business Data API's shape: the
955
+ * measures and dimensions datasets() lists. Counts and sums only, never a person. The account's
956
+ * token, crm:read, every plan; never an app key.
957
+ */
439
958
  aggregate(request) {
440
959
  return this.request('POST', '/api/my-crm/v1/data/query', request);
441
960
  }
961
+ /**
962
+ * What the contact list can be measured by: `people` (a count) and every number field the owner
963
+ * has named, each with its unit and other words for it. The catalogue for aggregate() and
964
+ * series(). The account's token, crm:read, every plan.
965
+ */
966
+ async metrics() {
967
+ return (await this.request('GET', '/api/my-crm/v1/data/metrics')).metrics;
968
+ }
969
+ /**
970
+ * One metric over time, every bucket filled (a bucket nobody landed in is 0 for counts and
971
+ * sums): `range` is a preset (7d, 30d, 90d, 12m, mtd, qtd, ytd, fytd, this_month, last_month,
972
+ * this_fy, last_fy) or give `from` and `to` (YYYY-MM-DD); with neither, the last 12 months.
973
+ * `compare: 'previous'` adds the window before. At most 800 points. The account's token,
974
+ * crm:read, every plan.
975
+ *
976
+ * @example
977
+ * const s = await db.series('people', { range: '90d', grain: 'week', compare: 'previous' });
978
+ * console.log(s.total, s.compare?.delta_pct);
979
+ */
980
+ series(metric, options = {}) {
981
+ const q = new URLSearchParams({ metric });
982
+ for (const k of ['range', 'from', 'to', 'grain', 'agg', 'compare', 'anchor', 'tz']) {
983
+ const v = options[k];
984
+ if (v)
985
+ q.set(k, v);
986
+ }
987
+ return this.request('GET', `/api/my-crm/v1/data/series?${q}`);
988
+ }
989
+ /** The contact list as a dataset: its columns by kind (measures, dimensions to group by, and the dates people can be counted by). The account's token, crm:read. */
990
+ async datasets() {
991
+ return (await this.request('GET', '/api/my-crm/v1/data/datasets')).datasets;
992
+ }
993
+ /** One dataset by its id (`contact`), or null when there is none by that id. The account's token, crm:read. */
994
+ async dataset(id) {
995
+ try {
996
+ const r = await this.request('GET', `/api/my-crm/v1/data/datasets/${encodeURIComponent(id)}`);
997
+ const { as_at: _asAt, ...dataset } = r;
998
+ return dataset;
999
+ }
1000
+ catch (err) {
1001
+ if (err instanceof AwesomateError && err.code === 'not_found')
1002
+ return null;
1003
+ throw err;
1004
+ }
1005
+ }
1006
+ /**
1007
+ * One app's AI assistant this month (UTC): how many conversations it looked at, replied to or
1008
+ * drafted for, how many replies asked Knowledge (those count towards Knowledge answers), and the
1009
+ * AI tokens, with how many were on the account's own AI key. The account's token, crm:read.
1010
+ */
1011
+ async assistantUsage(appId) {
1012
+ const r = await this.request('GET', `/api/my-crm/v1/apps/${encodeURIComponent(appId)}/assistant/usage`);
1013
+ return { ...r.usage, own_key: r.own_key };
1014
+ }
442
1015
  }
443
1016
  function memoryStorage() {
444
1017
  const m = new Map();
@@ -546,8 +1119,14 @@ export class AwesomateAppClient {
546
1119
  this.link = entry;
547
1120
  return entry.promise;
548
1121
  },
549
- /** The signed-in user, or null. */
1122
+ /** The signed-in user as this client last saw them (no call to the hub), or null. */
550
1123
  user: async () => (await this.load())?.user ?? null,
1124
+ /**
1125
+ * The signed-in person read fresh from the hub (their current role and linked contact), with
1126
+ * the app's own settings: its name, its voice agent and whether it takes uploads. A person
1127
+ * the account has disabled is signed out, and this rejects with code `unauthenticated`.
1128
+ */
1129
+ me: () => this.data('GET', '/me'),
551
1130
  /**
552
1131
  * Called with the user when someone signs in (or their details change: a new role, a linked
553
1132
  * contact) and with null on sign-out, including a refresh that was refused. A routine token
@@ -592,9 +1171,7 @@ export class AwesomateAppClient {
592
1171
  catch { /* a proxy's error page */ }
593
1172
  if (res.ok)
594
1173
  return json;
595
- const server = typeof json.code === 'string' ? json.code : undefined;
596
- const message = typeof json.error === 'string' ? json.error : `The hub answered ${res.status}.`;
597
- throw new AwesomateError(errorCode(res.status, server), message, res.status, typeof json.field === 'string' ? json.field : undefined, server, typeof json.message === 'string' ? json.message : undefined);
1174
+ throw hubError(res.status, json, typeof json.message === 'string' ? json.message : undefined);
598
1175
  }
599
1176
  async read() {
600
1177
  try {
@@ -703,6 +1280,7 @@ export class AwesomateAppClient {
703
1280
  /**
704
1281
  * Rows of one kind that this person may read, a page at a time. The database applies the
705
1282
  * kind's read rule for their role: a customer gets their own jobs, staff whatever theirs allows.
1283
+ * A signed-in person, on an account on Pro and above.
706
1284
  *
707
1285
  * @example
708
1286
  * const { rows } = await app.query('job', { orderBy: ['created_at', 'desc'], limit: 20 });
@@ -711,7 +1289,7 @@ export class AwesomateAppClient {
711
1289
  const r = await this.data('POST', '/rows/query', { kind, ...options });
712
1290
  return { rows: r.rows, next: r.next, count: r.count, applied: r.applied, asAt: r.as_at };
713
1291
  }
714
- /** Every matching row this person may read, a page at a time. maxRows (default 10,000) guards against reading a whole list by accident. */
1292
+ /** Every matching row this person may read, a page at a time. maxRows (default 10,000) guards against reading a whole list by accident. Pro and above. */
715
1293
  async *queryAll(kind, options = {}) {
716
1294
  const { maxRows = 10_000, ...rest } = options;
717
1295
  let after;
@@ -726,8 +1304,13 @@ export class AwesomateAppClient {
726
1304
  after = page.next ?? undefined;
727
1305
  } while (after);
728
1306
  }
729
- /** One record by id, or null when there is none this user may read. */
1307
+ /**
1308
+ * One record by id, or null when there is none this user may read. Ids are UUIDs: anything
1309
+ * else names no record, so it answers null without asking the hub. Pro and above.
1310
+ */
730
1311
  async get(kind, id) {
1312
+ if (!RECORD_ID.test(String(id)))
1313
+ return null;
731
1314
  try {
732
1315
  return (await this.data('GET', `/rows/${encodeURIComponent(kind)}/${encodeURIComponent(String(id))}`)).row;
733
1316
  }
@@ -737,14 +1320,14 @@ export class AwesomateAppClient {
737
1320
  throw err;
738
1321
  }
739
1322
  }
740
- /** Create a record (returns its id) or change one with options.id, inside the kind's write rule for this user. */
1323
+ /** Create a record (returns its id) or change one with options.id, inside the kind's write rule for this user. Pro and above. */
741
1324
  async write(kind, data, options = {}) {
742
1325
  const r = await this.data('POST', '/call', {
743
1326
  fn: 'write_record', args: { kind, data, links: options.links ?? {}, ...(options.id ? { id: options.id } : {}) },
744
1327
  });
745
1328
  return r.result.id;
746
1329
  }
747
- /** Archive a record, inside the kind's write rule for this person. */
1330
+ /** Archive a record, inside the kind's write rule for this person. Pro and above. */
748
1331
  async archive(kind, id) {
749
1332
  await this.data('POST', '/call', { fn: 'archive_record', args: { kind, id } });
750
1333
  }
@@ -752,7 +1335,7 @@ export class AwesomateAppClient {
752
1335
  * Run a recipe the account opened to this person's role: "accept this quote", something their
753
1336
  * write rule would not let them do by hand. Every step commits or none does; a recipe they may
754
1337
  * not run reads as not found. Never retried once sent: a write that may have landed is not safe
755
- * to send twice (a refused sign-in is, and is retried after a refresh like every data call).
1338
+ * to send twice (a refused sign-in is, and is retried after a refresh like every data call). Pro and above.
756
1339
  */
757
1340
  async call(recipe, args = {}) {
758
1341
  return (await this.data('POST', '/call', { fn: recipe, args })).result;
@@ -760,7 +1343,7 @@ export class AwesomateAppClient {
760
1343
  /**
761
1344
  * Customers by name or email (two characters or more), or by id, so staff can find one and put
762
1345
  * a record under them. Only for a role the account lets look customers up in this app; anyone
763
- * else gets a forbidden error. Name, email and phone only, at most 50.
1346
+ * else gets a forbidden error. Name, email and phone only, at most 50. Pro and above.
764
1347
  */
765
1348
  async lookupCustomers(options) {
766
1349
  return (await this.data('POST', '/customers/lookup', options)).customers;
@@ -800,9 +1383,8 @@ export class AwesomateAppClient {
800
1383
  json = text ? JSON.parse(text) : {};
801
1384
  }
802
1385
  catch { /* a proxy's error page */ }
803
- const server = typeof json.code === 'string' ? json.code : undefined;
804
- const message = typeof json.error === 'string' ? json.error : `The hub answered ${res.status}.`;
805
- throw new AwesomateError(errorCode(res.status, server), message, res.status, undefined, server, message);
1386
+ const err = hubError(res.status, json);
1387
+ throw new AwesomateError(err.code, err.message, err.status, undefined, err.serverCode, err.message, err);
806
1388
  }
807
1389
  /**
808
1390
  * Files people attach in this app, such as a photo of a job or a signed form, kept in the
@@ -867,7 +1449,7 @@ export class AwesomateAppClient {
867
1449
  * A list kept current: onRows gets the whole list (up to 200 rows, the query's order) at first
868
1450
  * and again whenever a row in it is added, changed, removed, or stops being one this person may
869
1451
  * read. One connection serves every live list on this client; it reconnects by itself and takes
870
- * a fresh snapshot when it does. Returns stop().
1452
+ * a fresh snapshot when it does. Returns stop(). Pro and above.
871
1453
  */
872
1454
  live(kind, options, handlers) {
873
1455
  const h = typeof handlers === 'function' ? { onRows: handlers } : handlers;
@@ -888,7 +1470,7 @@ export class AwesomateAppClient {
888
1470
  * assistant appears while it writes a reply. The hub checks this person may read the record, and
889
1471
  * keeps checking; onError hears a refusal. Shares the one live connection. Two parts of a page (a
890
1472
  * thread and a sidebar) can open the same record: each hears onPeople, and it stays open until both
891
- * have left. Returns typing() and leave().
1473
+ * have left. Returns typing() and leave(). Pro and above.
892
1474
  */
893
1475
  here(recordId, onPeople, onError) {
894
1476
  return this.liveSocket().join(recordId, onPeople, onError);
@@ -1238,6 +1820,124 @@ export function createAppClient(options) {
1238
1820
  export function createClient(options) {
1239
1821
  return new AwesomateClient(options);
1240
1822
  }
1823
+ const JWKS_TTL_MS = 5 * 60_000;
1824
+ const jwksCache = new Map();
1825
+ function b64urlBytes(s) {
1826
+ const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (s.length % 4)) % 4);
1827
+ const bin = atob(b64);
1828
+ const out = new Uint8Array(bin.length);
1829
+ for (let i = 0; i < bin.length; i++)
1830
+ out[i] = bin.charCodeAt(i);
1831
+ return out;
1832
+ }
1833
+ async function subtleCrypto() {
1834
+ const subtle = globalThis.crypto?.subtle;
1835
+ if (subtle)
1836
+ return subtle;
1837
+ // Node 18 has Web Crypto without the global; a bundler never sees this specifier.
1838
+ const spec = 'node:crypto';
1839
+ return (await import(spec)).webcrypto.subtle;
1840
+ }
1841
+ /** A token naming a key we lack refetches at most this often, so made-up key ids cannot make every request fetch. */
1842
+ const JWKS_REFETCH_MS = 30_000;
1843
+ async function hubKeys(url, doFetch, fresh) {
1844
+ const hit = jwksCache.get(url);
1845
+ const age = hit ? Date.now() - hit.at : Infinity;
1846
+ if (hit && (fresh ? age < JWKS_REFETCH_MS : age < JWKS_TTL_MS))
1847
+ return hit.keys;
1848
+ let res;
1849
+ try {
1850
+ res = await doFetch(url, { headers: { accept: 'application/json' } });
1851
+ }
1852
+ catch (err) {
1853
+ throw new AwesomateError('unavailable', `The hub's keys could not be read: ${err.message}`, 0);
1854
+ }
1855
+ if (!res.ok)
1856
+ throw new AwesomateError('unavailable', `The hub's keys could not be read (${res.status}).`, res.status);
1857
+ let keys;
1858
+ try {
1859
+ keys = (await res.json()).keys ?? [];
1860
+ }
1861
+ catch {
1862
+ throw new AwesomateError('unavailable', 'The hub\'s keys could not be read (not JSON).', res.status);
1863
+ }
1864
+ jwksCache.set(url, { at: Date.now(), keys });
1865
+ return keys;
1866
+ }
1867
+ /**
1868
+ * Verify an access token one of the account's apps sent your own server (from
1869
+ * app.auth.accessToken()), against the hub's public keys: ES256, signed by the hub, for this app,
1870
+ * not expired. Answers its claims; anything else rejects with code `unauthenticated`. The keys
1871
+ * are fetched from `<baseUrl>/api/sdk/v1/jwks.json` and kept for five minutes. Treat `role` as a
1872
+ * hint: the hub reads the person's current role on every call it answers.
1873
+ *
1874
+ * @example
1875
+ * import { verifyAppToken } from '@awesomate/sdk';
1876
+ *
1877
+ * const accessToken = 'the token your page sent, from app.auth.accessToken()';
1878
+ * const claims = await verifyAppToken(accessToken, { appId: 'app_your_app_id' });
1879
+ * console.log(claims.sub, claims.role);
1880
+ */
1881
+ export async function verifyAppToken(token, options) {
1882
+ const bad = (why) => { throw new AwesomateError('unauthenticated', why, 401); };
1883
+ if (!options?.appId)
1884
+ throw new Error('verifyAppToken needs the app\'s id (app_...): a token is only good for the app it was made for.');
1885
+ const parts = typeof token === 'string' ? token.split('.') : [];
1886
+ if (parts.length !== 3 || token.length > 4096)
1887
+ bad('That is not a sign-in token.');
1888
+ let header;
1889
+ let claims;
1890
+ try {
1891
+ const text = (s) => new TextDecoder().decode(b64urlBytes(s));
1892
+ header = JSON.parse(text(parts[0]));
1893
+ claims = JSON.parse(text(parts[1]));
1894
+ }
1895
+ catch {
1896
+ return bad('That is not a sign-in token.');
1897
+ }
1898
+ if (header.alg !== 'ES256' || !header.kid)
1899
+ bad('That sign-in token was not signed by the hub.');
1900
+ const doFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
1901
+ if (!doFetch)
1902
+ throw new Error('No fetch available: use Node 18 or later, or pass fetch.');
1903
+ const url = `${(options.baseUrl ?? DEFAULT_BASE).replace(/\/+$/, '')}/api/sdk/v1/jwks.json`;
1904
+ let jwk = (await hubKeys(url, doFetch, false)).find((k) => k.kid === header.kid);
1905
+ // A key the hub has added since the keys were last read.
1906
+ if (!jwk)
1907
+ jwk = (await hubKeys(url, doFetch, true)).find((k) => k.kid === header.kid);
1908
+ if (!jwk)
1909
+ return bad('That sign-in was not issued by this hub, or its key has been retired.');
1910
+ const subtle = await subtleCrypto();
1911
+ const key = await subtle.importKey('jwk', { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y, ext: true }, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
1912
+ const ok = await subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, b64urlBytes(parts[2]), new TextEncoder().encode(`${parts[0]}.${parts[1]}`));
1913
+ if (!ok)
1914
+ bad('That sign-in token is not valid.');
1915
+ if (claims.iss !== (options.issuer ?? 'https://hub.awesomate.ai'))
1916
+ bad('That sign-in token was not issued by this hub.');
1917
+ if (claims.aud !== `awesomate-app:${options.appId}` || claims.app !== options.appId)
1918
+ bad('That sign-in token is for a different app.');
1919
+ if (typeof claims.exp !== 'number' || claims.exp * 1000 <= Date.now())
1920
+ bad('That sign-in has expired. Refresh it or sign in again.');
1921
+ if (typeof claims.sub !== 'string' || !claims.sub)
1922
+ bad('That sign-in token is not valid.');
1923
+ return {
1924
+ sub: claims.sub,
1925
+ app: claims.app,
1926
+ role: typeof claims.role === 'string' ? claims.role : 'member',
1927
+ contact: typeof claims.contact === 'string' ? claims.contact : null,
1928
+ exp: claims.exp,
1929
+ iat: typeof claims.iat === 'number' ? claims.iat : null,
1930
+ };
1931
+ }
1932
+ function staffBooking(b) {
1933
+ return {
1934
+ bookingId: b.booking_id, status: b.status, service: b.service,
1935
+ serviceName: b.service_name ?? null, calendar: b.calendar, calendarName: b.calendar_name ?? null,
1936
+ startsAt: b.starts_at, endsAt: b.ends_at, seats: b.seats, source: b.source ?? null,
1937
+ answers: b.answers ?? null, contactId: b.contact_id,
1938
+ customer: b.customer ?? null, createdAt: b.created_at, cancelledAt: b.cancelled_at ?? null,
1939
+ };
1940
+ }
1241
1941
  /** The token in a booking's manage link (`/booking?t=...`), or null. */
1242
1942
  export function manageTokenFrom(url) {
1243
1943
  try {
@@ -1282,9 +1982,8 @@ export class AwesomateBookingsClient {
1282
1982
  }
1283
1983
  const data = (await res.json().catch(() => ({})));
1284
1984
  if (!res.ok) {
1285
- const server = typeof data.code === 'string' ? data.code : undefined;
1286
- const message = typeof data.error === 'string' ? data.error : `Request failed (${res.status})`;
1287
- throw new AwesomateError(errorCode(res.status, server), message, res.status, typeof data.field === 'string' ? data.field : undefined, server, message);
1985
+ const err = hubError(res.status, data);
1986
+ throw new AwesomateError(err.code, err.message, err.status, err.field, err.serverCode, err.message, err);
1288
1987
  }
1289
1988
  return data;
1290
1989
  }
@@ -1312,8 +2011,13 @@ export class AwesomateBookingsClient {
1312
2011
  }
1313
2012
  /**
1314
2013
  * Book a time for a visitor. They get an email with an invite and a link to change or cancel;
1315
- * the business gets a notice. A time taken since you listed it is refused with code `conflict`
1316
- * and `field` saying why (not_open, slot_taken, session_full, day_full): list the times again.
2014
+ * the business gets a notice. A refusal has code `conflict` and `field` saying why:
2015
+ * - `not_open`, `slot_taken`, `too_many_seats`, `session_full`, `day_full`: the time is not
2016
+ * free (taken since you listed it, or not enough places left): list the times again;
2017
+ * - `too_many_open`: this email already has three bookings coming up;
2018
+ * - `monthly_limit`: the business has taken its plan's online bookings for the month.
2019
+ *
2020
+ * Show `personMessage` to the visitor for the last two.
1317
2021
  */
1318
2022
  async book(request) {
1319
2023
  const r = await this.request('POST', '/book', {
@@ -1322,17 +2026,18 @@ export class AwesomateBookingsClient {
1322
2026
  });
1323
2027
  return { bookingId: r.booking_id, created: r.created, startsAt: r.starts_at, endsAt: r.ends_at, manageUrl: r.manage_url };
1324
2028
  }
1325
- /** One booking, from the token in its manage link (manageTokenFrom()). */
2029
+ /** One booking, from the token in its manage link (manageTokenFrom()), with how the business looks so the page can match it. */
1326
2030
  async booking(manageToken) {
1327
2031
  const r = await this.request('GET', `/manage?t=${encodeURIComponent(manageToken)}`, undefined, false);
1328
2032
  const b = r.booking;
1329
2033
  return {
1330
2034
  business: r.business,
2035
+ look: { website: r.look?.website ?? null, logo: r.look?.logo ?? null, colour: r.look?.colour ?? null },
1331
2036
  booking: {
1332
2037
  bookingId: b.booking_id, status: b.status, service: b.service ?? null,
1333
2038
  with: b.with ?? null, startsAt: b.starts_at, endsAt: b.ends_at, timezone: b.timezone,
1334
2039
  location: b.location ?? '', seats: b.seats, canCancel: !!b.can_cancel, canMove: !!b.can_move,
1335
- changesCloseAt: b.changes_close_at,
2040
+ changesCloseAt: b.changes_close_at, priceText: b.price_text ?? '',
1336
2041
  },
1337
2042
  };
1338
2043
  }
@@ -1347,11 +2052,18 @@ export class AwesomateBookingsClient {
1347
2052
  q.set('to', to);
1348
2053
  return (await this.request('GET', `/manage/open-times?${q}`, undefined, false)).times;
1349
2054
  }
1350
- /** Cancel a booking from its manage link. A second cancel answers cancelled: false. */
2055
+ /**
2056
+ * Cancel a booking from its manage link. A second cancel answers cancelled: false. Once changes
2057
+ * have closed (`canCancel` false) it is refused with code `conflict`, field `too_late`.
2058
+ */
1351
2059
  cancel(manageToken, reason) {
1352
2060
  return this.request('POST', '/manage/cancel', { t: manageToken, reason }, false);
1353
2061
  }
1354
- /** Move a booking to a time from openTimesToMove(). */
2062
+ /**
2063
+ * Move a booking to a time from openTimesToMove(). Refused with code `conflict` and field
2064
+ * `too_late` once changes have closed, or `not_open` (or another of book()'s reasons) when the
2065
+ * time is no longer free.
2066
+ */
1355
2067
  async move(manageToken, startsAt) {
1356
2068
  const r = await this.request('POST', '/manage/move', { t: manageToken, starts_at: startsAt }, false);
1357
2069
  return { startsAt: r.starts_at, endsAt: r.ends_at };