@awesomate/sdk 0.25.0 → 0.27.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 +1009 -63
  2. package/dist/index.js +572 -71
  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,14 +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.25.0';
29
+ export const VERSION = '0.27.0';
28
30
  const DEFAULT_BASE = 'https://hub.awesomate.ai';
29
31
  /** Every record and contact id is a UUID; anything else can name no row. */
30
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;
31
- const ERROR_CODES = ['unauthenticated', 'forbidden', 'not_found', 'validation', 'consent_blocked', 'rate_limited', 'conflict', 'unavailable'];
33
+ const ERROR_CODES = [
34
+ 'unauthenticated', 'forbidden', 'not_found', 'validation', 'consent_blocked', 'rate_limited', 'conflict', 'unavailable',
35
+ 'feature_unavailable', 'upgrade_required', 'scope_missing',
36
+ ];
32
37
  /**
33
38
  * Every failure from the hub. `message` is for you, the developer; `personMessage`, when the hub
34
- * 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`.
35
42
  */
36
43
  export class AwesomateError extends Error {
37
44
  code;
@@ -39,6 +46,16 @@ export class AwesomateError extends Error {
39
46
  field;
40
47
  serverCode;
41
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;
42
59
  constructor(
43
60
  /** Why it failed. */
44
61
  code, message,
@@ -49,7 +66,9 @@ export class AwesomateError extends Error {
49
66
  /** The hub's own code, unmapped. */
50
67
  serverCode,
51
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"). */
52
- personMessage) {
69
+ personMessage,
70
+ /** The refusal's typed fields. */
71
+ details = {}) {
53
72
  super(message);
54
73
  this.code = code;
55
74
  this.status = status;
@@ -57,10 +76,24 @@ export class AwesomateError extends Error {
57
76
  this.serverCode = serverCode;
58
77
  this.personMessage = personMessage;
59
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;
60
89
  }
61
90
  }
62
- const CONSENT_CODES = new Set(['consent_blocked', 'consent_denied', 'files_consent_off']);
63
- 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 = {}) {
64
97
  if (server === 'validation' || server === 'invalid' || status === 422)
65
98
  return 'validation';
66
99
  // A file the hub will not take as sent: too big, no length, or a type it refuses.
@@ -68,8 +101,17 @@ function errorCode(status, server) {
68
101
  return 'validation';
69
102
  if (status === 401)
70
103
  return 'unauthenticated';
71
- if (status === 403)
72
- 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
+ }
73
115
  if (status === 429)
74
116
  return 'rate_limited';
75
117
  if (server === 'conflict')
@@ -78,6 +120,43 @@ function errorCode(status, server) {
78
120
  return 'not_found';
79
121
  return 'unavailable';
80
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
+ }
81
160
  const ACCOUNT = '/api/my-crm/v1';
82
161
  const KEY_ROUTES = [
83
162
  ['GET', /^\/rows\/schema$/], ['POST', /^\/rows\/query$/], ['GET', /^\/rows\/[^/]+\/[^/?]+(\?.*)?$/], ['POST', /^\/call$/],
@@ -111,13 +190,14 @@ export class AwesomateClient {
111
190
  throw new Error('No fetch available: use Node 18 or later, or pass fetch.');
112
191
  }
113
192
  async request(method, path, body, retry = true) {
114
- if (this.appKey)
115
- path = keyPath(method, path);
116
- 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}`, {
117
195
  method,
118
196
  headers: {
119
197
  authorization: `Bearer ${this.opts.token}`,
120
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',
121
201
  ...(body === undefined ? {} : { 'content-type': 'application/json' }),
122
202
  },
123
203
  body: body === undefined ? undefined : JSON.stringify(body),
@@ -130,14 +210,12 @@ export class AwesomateClient {
130
210
  catch { /* a proxy's HTML error page */ }
131
211
  if (res.ok)
132
212
  return json;
133
- const server = typeof json.code === 'string' ? json.code : undefined;
134
- const code = errorCode(res.status, server);
213
+ const err = hubError(res.status, json);
135
214
  // The field list changed while the read ran (the view is rebuilt on every field edit): a read
136
215
  // is safe to run again, once.
137
- if (code === 'conflict' && retry)
216
+ if (err.code === 'conflict' && retry)
138
217
  return this.request(method, path, body, false);
139
- const message = typeof json.error === 'string' ? json.error : `The hub answered ${res.status}.`;
140
- throw new AwesomateError(code, message, res.status, typeof json.field === 'string' ? json.field : undefined, server);
218
+ throw err;
141
219
  }
142
220
  /** A call whose body or answer is a file, not JSON. Answers the Response when it succeeded. */
143
221
  async raw(method, path, body) {
@@ -161,9 +239,7 @@ export class AwesomateClient {
161
239
  json = text ? JSON.parse(text) : {};
162
240
  }
163
241
  catch { /* a proxy's HTML error page */ }
164
- const server = typeof json.code === 'string' ? json.code : undefined;
165
- const message = typeof json.error === 'string' ? json.error : `The hub answered ${res.status}.`;
166
- throw new AwesomateError(errorCode(res.status, server), message, res.status, undefined, server);
242
+ throw hubError(res.status, json);
167
243
  }
168
244
  filesCall(method, path, body) {
169
245
  if (this.appKey) {
@@ -249,6 +325,20 @@ export class AwesomateClient {
249
325
  const r = await this.filesCall('POST', '/api/files/trash/empty', days === undefined ? {} : { older_than_days: days });
250
326
  return { removed: r.removed, bytes: r.bytes, files: r.files };
251
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'),
252
342
  };
253
343
  tasksCall(method, path, body) {
254
344
  if (this.appKey) {
@@ -259,35 +349,50 @@ export class AwesomateClient {
259
349
  /**
260
350
  * The account's Tasks: what is waiting on its people, and tasks they give each other. An app's
261
351
  * server or an n8n workflow can give someone a task ("call this customer back") and finish it.
262
- * Needs the account's token, and Tasks on the account (a feature_unavailable refusal says
263
- * 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.
264
355
  *
265
356
  * @example
266
357
  * await db.tasks.give({ title: 'Call Sam back about the leak', forEmail: 'jo@brightwater.example', dueAt: '2026-10-06T09:00:00+11:00' });
267
358
  */
268
359
  tasks = {
269
- /** 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. */
270
361
  board: (options = {}) => this.tasksCall('GET', `/api/my-tasks${options.scope === 'all' ? '?scope=all' : ''}`),
271
- /** 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. */
272
376
  give: async (task) => {
273
377
  const r = await this.tasksCall('POST', '/api/my-tasks/tasks', task);
274
378
  return { key: r.key, id: r.id, holder: r.holder, emailed: r.emailed };
275
379
  },
276
- /** Change a given task: whoever wrote it, or the owner. */
380
+ /** Change a given task: whoever wrote it, or the owner. hosting:manage. */
277
381
  update: async (id, patch) => {
278
382
  await this.tasksCall('PATCH', `/api/my-tasks/tasks/${encodeURIComponent(String(id))}`, patch);
279
383
  },
280
- /** 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. */
281
385
  done: async (id) => {
282
386
  await this.tasksCall('POST', `/api/my-tasks/tasks/${encodeURIComponent(String(id))}/done`);
283
387
  },
284
- /** 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. */
285
389
  drop: async (id) => {
286
390
  await this.tasksCall('POST', `/api/my-tasks/tasks/${encodeURIComponent(String(id))}/drop`);
287
391
  },
288
392
  /**
289
393
  * Open a done or dropped task again, with the person who had it. They get an email unless
290
394
  * they are the one bringing it back. Who may: see `decided[].canBringBack` on the board.
395
+ * hosting:manage.
291
396
  */
292
397
  bringBack: async (id) => {
293
398
  const r = await this.tasksCall('POST', `/api/my-tasks/tasks/${encodeURIComponent(String(id))}/reopen`);
@@ -296,21 +401,21 @@ export class AwesomateClient {
296
401
  /**
297
402
  * What happened to a given task, in order: who gave it, who passed it on, who closed it and
298
403
  * brought it back, and how long each person had it. The owner reads any task's record; anyone
299
- * else only one they were part of.
404
+ * else only one they were part of. hosting:read.
300
405
  */
301
406
  record: (id) => this.tasksCall('GET', `/api/my-tasks/tasks/${encodeURIComponent(String(id))}/record`),
302
- /** Put a card off until a time (at most 90 days), for this person only. */
407
+ /** Put a card off until a time (at most 90 days), for this person only. hosting:manage. */
303
408
  notNow: async (key, until) => {
304
409
  const r = await this.tasksCall('POST', `/api/my-tasks/items/${encodeURIComponent(key)}/not-now`, {
305
410
  until: until instanceof Date ? until.toISOString() : until,
306
411
  });
307
412
  return { until: r.until };
308
413
  },
309
- /** Bring a card put off with notNow() back now, for this person. */
414
+ /** Bring a card put off with notNow() back now, for this person. hosting:manage. */
310
415
  cancelNotNow: async (key) => {
311
416
  await this.tasksCall('DELETE', `/api/my-tasks/items/${encodeURIComponent(key)}/not-now`);
312
417
  },
313
- /** Hand a card to someone on the account who can act on it. */
418
+ /** Hand a card to someone on the account who can act on it. hosting:manage. */
314
419
  passOn: async (key, toEmail, reason) => {
315
420
  const r = await this.tasksCall('POST', `/api/my-tasks/items/${encodeURIComponent(key)}/pass-on`, {
316
421
  toEmail, ...(reason ? { reason } : {}),
@@ -331,6 +436,12 @@ export class AwesomateClient {
331
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' });
332
437
  */
333
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),
334
445
  /**
335
446
  * Bookings that start in a window, soonest first, each with its customer. The window is from a
336
447
  * day ago for 31 days unless told otherwise; `limit` is 1 to 500 (default 200).
@@ -419,6 +530,164 @@ export class AwesomateClient {
419
530
  await this.request('POST', `/api/my-crm/v1/bookings/${encodeURIComponent(String(id))}/outcome`, { status }, false);
420
531
  },
421
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
+ };
422
691
  /**
423
692
  * Tell Contacts something happened to someone ("job_paid", "quote_sent"), so any email series
424
693
  * waiting for that event starts or stops for them. The person is found by email, or added; it
@@ -438,16 +707,44 @@ export class AwesomateClient {
438
707
  }, false);
439
708
  return { duplicate: !!r.duplicate };
440
709
  }
441
- /** The kinds this token can read, their columns, operators and examples. */
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), and whose key it
713
+ * is: the account owner's, which acts as the owner, or a team member's own, which does only what
714
+ * that person can do in the hub. Needs hosting:read; an app key cannot ask (it reads and writes
715
+ * its own kinds only).
716
+ *
717
+ * @example
718
+ * const me = await db.whoami();
719
+ * if (!me.features?.bookings?.available) console.log(me.features?.bookings?.message);
720
+ */
721
+ async whoami() {
722
+ const r = await this.request('GET', '/api/hosting-access/context');
723
+ return {
724
+ account: { contactId: r.contactId, slug: r.slug ?? null, email: r.email ?? null },
725
+ plan: r.plan ?? null,
726
+ scopes: Array.isArray(r.scopes) ? r.scopes.map(String) : [],
727
+ tokenExpiresAt: r.patExpiresAt ?? null,
728
+ features: r.features ?? null,
729
+ key: r.key ?? null,
730
+ };
731
+ }
732
+ /**
733
+ * The kinds this token can read: each kind's columns with their types, operators, choices and
734
+ * the owner's descriptions and sections, how many fields are hidden and why, the query grammar
735
+ * and examples. Works with the account's token (crm:read, every plan, Contacts on the account)
736
+ * and with an app key (its own kinds, and `access` says whether it can write).
737
+ */
442
738
  schema() {
443
739
  return this.request('GET', '/api/my-crm/v1/rows/schema');
444
740
  }
445
- /** The readable kinds as a TypeScript file (what the types command writes). */
741
+ /** The readable kinds as a TypeScript file (what the types command writes). The account's token, crm:read. */
446
742
  types() {
447
743
  return this.request('GET', '/api/my-crm/v1/rows/types');
448
744
  }
449
745
  /**
450
- * Rows of one kind that this token can read, a page at a time.
746
+ * Rows of one kind that this token can read, a page at a time. The account's token (crm:read,
747
+ * every plan, Contacts on the account) or an app key, for the kinds it was made for.
451
748
  *
452
749
  * @example
453
750
  * const { rows, next } = await db.query('contact', {
@@ -460,7 +757,7 @@ export class AwesomateClient {
460
757
  const r = await this.request('POST', '/api/my-crm/v1/rows/query', { kind, ...options });
461
758
  return { rows: r.rows, next: r.next, count: r.count, applied: r.applied, hidden: r.hidden, asAt: r.as_at };
462
759
  }
463
- /** Every matching row, a page at a time. maxRows guards against reading a whole list by accident. */
760
+ /** Every matching row, a page at a time. maxRows guards against reading a whole list by accident. Needs what query() needs. */
464
761
  async *queryAll(kind, options = {}) {
465
762
  const { maxRows = 10_000, ...rest } = options;
466
763
  let after;
@@ -477,7 +774,7 @@ export class AwesomateClient {
477
774
  }
478
775
  /**
479
776
  * One row by id, or null when there is none this token can read. Ids are UUIDs: anything else
480
- * names no row, so it answers null without asking the hub.
777
+ * names no row, so it answers null without asking the hub. Needs what query() needs.
481
778
  */
482
779
  async get(kind, id, options = {}) {
483
780
  if (!RECORD_ID.test(String(id)))
@@ -493,64 +790,88 @@ export class AwesomateClient {
493
790
  throw err;
494
791
  }
495
792
  }
496
- /** The app kinds this account has defined. */
793
+ /** The app kinds this account has defined. The account's token, crm:read, every plan; never an app key. */
497
794
  async kinds() {
498
795
  return (await this.request('GET', '/api/my-crm/v1/kinds')).kinds;
499
796
  }
500
- /** Define an app kind (a table an app keeps). No migration: registry rows and generated views. */
797
+ /**
798
+ * Define an app kind (a table an app keeps). No migration: registry rows and generated views.
799
+ * The account's token, crm:write, Support Plus and above; never an app key.
800
+ */
501
801
  async defineKind(spec) {
502
802
  return (await this.request('POST', '/api/my-crm/v1/kinds', spec)).kind;
503
803
  }
504
- /** Create a record (returns its id), or change one with options.id. Every value is checked against the kind. */
804
+ /**
805
+ * Add an attribute to a kind you defined; records already there have no value for it. Answers
806
+ * the kind as it now is. The account's token, crm:write, Support Plus and above.
807
+ */
808
+ async addAttribute(kind, attribute) {
809
+ return (await this.request('POST', `/api/my-crm/v1/kinds/${encodeURIComponent(kind)}/attributes`, { attribute }, false)).kind;
810
+ }
811
+ /**
812
+ * Archive an attribute: it leaves every read and the generated types, and its values are kept.
813
+ * Answers the kind as it now is. The account's token, crm:write, Support Plus and above.
814
+ */
815
+ async archiveAttribute(kind, key) {
816
+ return (await this.request('DELETE', `/api/my-crm/v1/kinds/${encodeURIComponent(kind)}/attributes/${encodeURIComponent(key)}`, undefined, false)).kind;
817
+ }
818
+ /**
819
+ * Create a record (returns its id), or change one with options.id. Every value is checked
820
+ * against the kind. crm:write, Support Plus and above: the account's token, or an app key made
821
+ * with write access for this kind.
822
+ */
505
823
  async write(kind, data, options = {}) {
506
824
  const r = await this.request('POST', '/api/my-crm/v1/call', {
507
825
  fn: 'write_record', args: { kind, data, links: options.links ?? {}, ...(options.id ? { id: options.id } : {}) },
508
826
  }, false);
509
827
  return r.result.id;
510
828
  }
511
- /** Archive a record: it leaves every read and is kept for a restore. */
829
+ /** Archive a record: it leaves every read and is kept for a restore. Needs what write() needs. */
512
830
  async archive(kind, id) {
513
831
  await this.request('POST', '/api/my-crm/v1/call', { fn: 'archive_record', args: { kind, id } }, false);
514
832
  }
515
- /** The saved queries on this account. */
833
+ /** The saved queries on this account. crm:read, every plan: the account's token or an app key. */
516
834
  async queries() {
517
835
  return (await this.request('GET', '/api/my-crm/v1/queries')).queries;
518
836
  }
519
837
  /**
520
838
  * Save a query by name (saving a name again replaces it). The spec is the query() grammar on one
521
839
  * kind, with {"$param": "<name>"} where a caller's value goes; it is compiled before it is
522
- * stored, so a column it cannot read is refused now rather than when it runs.
840
+ * stored, so a column it cannot read is refused now rather than when it runs. The account's
841
+ * token, crm:write, Support Plus and above; never an app key.
523
842
  */
524
843
  async saveQuery(spec) {
525
844
  return (await this.request('POST', '/api/my-crm/v1/queries', spec, false)).query;
526
845
  }
527
- /** Archive a saved query by its key. */
846
+ /** Archive a saved query by its key. The account's token, crm:write, Support Plus and above. */
528
847
  async archiveQuery(key) {
529
848
  await this.request('DELETE', `/api/my-crm/v1/queries/${encodeURIComponent(key)}`, undefined, false);
530
849
  }
531
- /** Run a saved query with its params. An optional param left out drops its condition. */
850
+ /** 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. */
532
851
  async run(query, params = {}, options = {}) {
533
852
  const r = await this.request('POST', `/api/my-crm/v1/queries/${encodeURIComponent(query)}/run`, { params, ...options });
534
853
  return { rows: r.rows, next: r.next, count: r.count, applied: r.applied, hidden: r.hidden, asAt: r.as_at };
535
854
  }
536
- /** The write recipes on this account. */
855
+ /** The write recipes on this account. crm:read, every plan: the account's token or an app key. */
537
856
  async recipes() {
538
857
  return (await this.request('GET', '/api/my-crm/v1/recipes')).recipes;
539
858
  }
540
859
  /**
541
860
  * Save a write recipe: up to 10 write_record / archive_record steps run in one transaction.
542
861
  * {"$param": "<name>"} takes a caller's value, {"$step": "<as>"} the id an earlier step wrote.
862
+ * The account's token, crm:write, Support Plus and above; never an app key.
543
863
  */
544
864
  async saveRecipe(spec) {
545
865
  return (await this.request('POST', '/api/my-crm/v1/recipes', spec, false)).recipe;
546
866
  }
547
- /** Archive a write recipe by its key. */
867
+ /** Archive a write recipe by its key. The account's token, crm:write, Support Plus and above. */
548
868
  async archiveRecipe(key) {
549
869
  await this.request('DELETE', `/api/my-crm/v1/recipes/${encodeURIComponent(key)}`, undefined, false);
550
870
  }
551
871
  /**
552
872
  * Run a write recipe. Every step commits or none does; a refusal names the step and the field.
553
- * Never retried: a write that may have landed is not safe to send twice.
873
+ * Never retried: a write that may have landed is not safe to send twice. crm:write, Support
874
+ * Plus and above: the account's token, or an app key with write access to every kind it writes.
554
875
  */
555
876
  async call(recipe, args = {}) {
556
877
  return (await this.request('POST', '/api/my-crm/v1/call', { fn: recipe, args }, false)).result;
@@ -574,20 +895,21 @@ export class AwesomateClient {
574
895
  async businessMap() {
575
896
  return (await this.request('GET', '/api/my-business/v2/map?format=json')).map;
576
897
  }
577
- /** Every role on the map, one line each: slug, title, department and who holds it. */
898
+ /** Every role on the map, one line each: slug, title, department and who holds it. hosting:read, the business map on the account. */
578
899
  async businessMapRoles() {
579
900
  return (await this.request('GET', '/api/my-business/v2/map/roles')).roles;
580
901
  }
581
902
  /**
582
903
  * One role by its slug, with the line each helper's instructions carry (`helpers[].instructionLine`):
583
- * which role it helps, for whom, how far it may go and where its procedures are.
904
+ * which role it helps, for whom, how far it may go and where its procedures are. hosting:read,
905
+ * the business map on the account.
584
906
  */
585
907
  async businessMapRole(slug) {
586
908
  return (await this.request('GET', `/api/my-business/v2/map/roles/${encodeURIComponent(slug)}`)).role;
587
909
  }
588
910
  /**
589
911
  * The business map in its first shape, where a department is a `division`, a sub-department a
590
- * `department` and a role a `job`.
912
+ * `department` and a role a `job`. hosting:read, the business map on the account.
591
913
  * @deprecated Use businessMap(), which uses the words the owner sees. v1 is removed once it has
592
914
  * gone 30 days unused, and not before 2026-11-06.
593
915
  */
@@ -605,7 +927,8 @@ export class AwesomateClient {
605
927
  /**
606
928
  * One of the business's documents with its text (the current version), or null when the
607
929
  * business has none of that kind yet. The text is the owner's own: use it for their business
608
- * (agent instructions, site copy in their voice), never show it to anyone else.
930
+ * (agent instructions, site copy in their voice), never show it to anyone else. The account's
931
+ * token, hosting:read, every plan.
609
932
  */
610
933
  async businessDocument(kind) {
611
934
  try {
@@ -617,14 +940,80 @@ export class AwesomateClient {
617
940
  throw err;
618
941
  }
619
942
  }
620
- /** Details waiting for the owner's yes on Your business: from the website, the account, a team member or a file. */
943
+ /** 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. */
621
944
  async businessSuggestions() {
622
945
  return (await this.request('GET', '/api/my-business/v1/proposals')).proposals;
623
946
  }
624
- /** Grouped numbers (counts, sums) in the Business Data API's shape. */
947
+ /**
948
+ * Every detail a business can have (key, label, group, type and longest value), set or not, for
949
+ * a page that offers the missing ones. No values: business() has those. The account's token,
950
+ * hosting:read, every plan.
951
+ */
952
+ businessCatalogue() {
953
+ return this.request('GET', '/api/my-business/v1/catalogue');
954
+ }
955
+ /**
956
+ * Grouped numbers (counts, sums) over the contact list, in the Business Data API's shape: the
957
+ * measures and dimensions datasets() lists. Counts and sums only, never a person. The account's
958
+ * token, crm:read, every plan; never an app key.
959
+ */
625
960
  aggregate(request) {
626
961
  return this.request('POST', '/api/my-crm/v1/data/query', request);
627
962
  }
963
+ /**
964
+ * What the contact list can be measured by: `people` (a count) and every number field the owner
965
+ * has named, each with its unit and other words for it. The catalogue for aggregate() and
966
+ * series(). The account's token, crm:read, every plan.
967
+ */
968
+ async metrics() {
969
+ return (await this.request('GET', '/api/my-crm/v1/data/metrics')).metrics;
970
+ }
971
+ /**
972
+ * One metric over time, every bucket filled (a bucket nobody landed in is 0 for counts and
973
+ * sums): `range` is a preset (7d, 30d, 90d, 12m, mtd, qtd, ytd, fytd, this_month, last_month,
974
+ * this_fy, last_fy) or give `from` and `to` (YYYY-MM-DD); with neither, the last 12 months.
975
+ * `compare: 'previous'` adds the window before. At most 800 points. The account's token,
976
+ * crm:read, every plan.
977
+ *
978
+ * @example
979
+ * const s = await db.series('people', { range: '90d', grain: 'week', compare: 'previous' });
980
+ * console.log(s.total, s.compare?.delta_pct);
981
+ */
982
+ series(metric, options = {}) {
983
+ const q = new URLSearchParams({ metric });
984
+ for (const k of ['range', 'from', 'to', 'grain', 'agg', 'compare', 'anchor', 'tz']) {
985
+ const v = options[k];
986
+ if (v)
987
+ q.set(k, v);
988
+ }
989
+ return this.request('GET', `/api/my-crm/v1/data/series?${q}`);
990
+ }
991
+ /** 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. */
992
+ async datasets() {
993
+ return (await this.request('GET', '/api/my-crm/v1/data/datasets')).datasets;
994
+ }
995
+ /** One dataset by its id (`contact`), or null when there is none by that id. The account's token, crm:read. */
996
+ async dataset(id) {
997
+ try {
998
+ const r = await this.request('GET', `/api/my-crm/v1/data/datasets/${encodeURIComponent(id)}`);
999
+ const { as_at: _asAt, ...dataset } = r;
1000
+ return dataset;
1001
+ }
1002
+ catch (err) {
1003
+ if (err instanceof AwesomateError && err.code === 'not_found')
1004
+ return null;
1005
+ throw err;
1006
+ }
1007
+ }
1008
+ /**
1009
+ * One app's AI assistant this month (UTC): how many conversations it looked at, replied to or
1010
+ * drafted for, how many replies asked Knowledge (those count towards Knowledge answers), and the
1011
+ * AI tokens, with how many were on the account's own AI key. The account's token, crm:read.
1012
+ */
1013
+ async assistantUsage(appId) {
1014
+ const r = await this.request('GET', `/api/my-crm/v1/apps/${encodeURIComponent(appId)}/assistant/usage`);
1015
+ return { ...r.usage, own_key: r.own_key };
1016
+ }
628
1017
  }
629
1018
  function memoryStorage() {
630
1019
  const m = new Map();
@@ -732,8 +1121,14 @@ export class AwesomateAppClient {
732
1121
  this.link = entry;
733
1122
  return entry.promise;
734
1123
  },
735
- /** The signed-in user, or null. */
1124
+ /** The signed-in user as this client last saw them (no call to the hub), or null. */
736
1125
  user: async () => (await this.load())?.user ?? null,
1126
+ /**
1127
+ * The signed-in person read fresh from the hub (their current role and linked contact), with
1128
+ * the app's own settings: its name, its voice agent and whether it takes uploads. A person
1129
+ * the account has disabled is signed out, and this rejects with code `unauthenticated`.
1130
+ */
1131
+ me: () => this.data('GET', '/me'),
737
1132
  /**
738
1133
  * Called with the user when someone signs in (or their details change: a new role, a linked
739
1134
  * contact) and with null on sign-out, including a refresh that was refused. A routine token
@@ -778,9 +1173,7 @@ export class AwesomateAppClient {
778
1173
  catch { /* a proxy's error page */ }
779
1174
  if (res.ok)
780
1175
  return json;
781
- const server = typeof json.code === 'string' ? json.code : undefined;
782
- const message = typeof json.error === 'string' ? json.error : `The hub answered ${res.status}.`;
783
- 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);
1176
+ throw hubError(res.status, json, typeof json.message === 'string' ? json.message : undefined);
784
1177
  }
785
1178
  async read() {
786
1179
  try {
@@ -889,6 +1282,7 @@ export class AwesomateAppClient {
889
1282
  /**
890
1283
  * Rows of one kind that this person may read, a page at a time. The database applies the
891
1284
  * kind's read rule for their role: a customer gets their own jobs, staff whatever theirs allows.
1285
+ * A signed-in person, on an account on Pro and above.
892
1286
  *
893
1287
  * @example
894
1288
  * const { rows } = await app.query('job', { orderBy: ['created_at', 'desc'], limit: 20 });
@@ -897,7 +1291,7 @@ export class AwesomateAppClient {
897
1291
  const r = await this.data('POST', '/rows/query', { kind, ...options });
898
1292
  return { rows: r.rows, next: r.next, count: r.count, applied: r.applied, asAt: r.as_at };
899
1293
  }
900
- /** Every matching row this person may read, a page at a time. maxRows (default 10,000) guards against reading a whole list by accident. */
1294
+ /** 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. */
901
1295
  async *queryAll(kind, options = {}) {
902
1296
  const { maxRows = 10_000, ...rest } = options;
903
1297
  let after;
@@ -914,7 +1308,7 @@ export class AwesomateAppClient {
914
1308
  }
915
1309
  /**
916
1310
  * One record by id, or null when there is none this user may read. Ids are UUIDs: anything
917
- * else names no record, so it answers null without asking the hub.
1311
+ * else names no record, so it answers null without asking the hub. Pro and above.
918
1312
  */
919
1313
  async get(kind, id) {
920
1314
  if (!RECORD_ID.test(String(id)))
@@ -928,14 +1322,14 @@ export class AwesomateAppClient {
928
1322
  throw err;
929
1323
  }
930
1324
  }
931
- /** Create a record (returns its id) or change one with options.id, inside the kind's write rule for this user. */
1325
+ /** Create a record (returns its id) or change one with options.id, inside the kind's write rule for this user. Pro and above. */
932
1326
  async write(kind, data, options = {}) {
933
1327
  const r = await this.data('POST', '/call', {
934
1328
  fn: 'write_record', args: { kind, data, links: options.links ?? {}, ...(options.id ? { id: options.id } : {}) },
935
1329
  });
936
1330
  return r.result.id;
937
1331
  }
938
- /** Archive a record, inside the kind's write rule for this person. */
1332
+ /** Archive a record, inside the kind's write rule for this person. Pro and above. */
939
1333
  async archive(kind, id) {
940
1334
  await this.data('POST', '/call', { fn: 'archive_record', args: { kind, id } });
941
1335
  }
@@ -943,7 +1337,7 @@ export class AwesomateAppClient {
943
1337
  * Run a recipe the account opened to this person's role: "accept this quote", something their
944
1338
  * write rule would not let them do by hand. Every step commits or none does; a recipe they may
945
1339
  * not run reads as not found. Never retried once sent: a write that may have landed is not safe
946
- * to send twice (a refused sign-in is, and is retried after a refresh like every data call).
1340
+ * to send twice (a refused sign-in is, and is retried after a refresh like every data call). Pro and above.
947
1341
  */
948
1342
  async call(recipe, args = {}) {
949
1343
  return (await this.data('POST', '/call', { fn: recipe, args })).result;
@@ -951,7 +1345,7 @@ export class AwesomateAppClient {
951
1345
  /**
952
1346
  * Customers by name or email (two characters or more), or by id, so staff can find one and put
953
1347
  * a record under them. Only for a role the account lets look customers up in this app; anyone
954
- * else gets a forbidden error. Name, email and phone only, at most 50.
1348
+ * else gets a forbidden error. Name, email and phone only, at most 50. Pro and above.
955
1349
  */
956
1350
  async lookupCustomers(options) {
957
1351
  return (await this.data('POST', '/customers/lookup', options)).customers;
@@ -991,9 +1385,8 @@ export class AwesomateAppClient {
991
1385
  json = text ? JSON.parse(text) : {};
992
1386
  }
993
1387
  catch { /* a proxy's error page */ }
994
- const server = typeof json.code === 'string' ? json.code : undefined;
995
- const message = typeof json.error === 'string' ? json.error : `The hub answered ${res.status}.`;
996
- throw new AwesomateError(errorCode(res.status, server), message, res.status, undefined, server, message);
1388
+ const err = hubError(res.status, json);
1389
+ throw new AwesomateError(err.code, err.message, err.status, undefined, err.serverCode, err.message, err);
997
1390
  }
998
1391
  /**
999
1392
  * Files people attach in this app, such as a photo of a job or a signed form, kept in the
@@ -1058,7 +1451,7 @@ export class AwesomateAppClient {
1058
1451
  * A list kept current: onRows gets the whole list (up to 200 rows, the query's order) at first
1059
1452
  * and again whenever a row in it is added, changed, removed, or stops being one this person may
1060
1453
  * read. One connection serves every live list on this client; it reconnects by itself and takes
1061
- * a fresh snapshot when it does. Returns stop().
1454
+ * a fresh snapshot when it does. Returns stop(). Pro and above.
1062
1455
  */
1063
1456
  live(kind, options, handlers) {
1064
1457
  const h = typeof handlers === 'function' ? { onRows: handlers } : handlers;
@@ -1079,7 +1472,7 @@ export class AwesomateAppClient {
1079
1472
  * assistant appears while it writes a reply. The hub checks this person may read the record, and
1080
1473
  * keeps checking; onError hears a refusal. Shares the one live connection. Two parts of a page (a
1081
1474
  * thread and a sidebar) can open the same record: each hears onPeople, and it stays open until both
1082
- * have left. Returns typing() and leave().
1475
+ * have left. Returns typing() and leave(). Pro and above.
1083
1476
  */
1084
1477
  here(recordId, onPeople, onError) {
1085
1478
  return this.liveSocket().join(recordId, onPeople, onError);
@@ -1429,6 +1822,115 @@ export function createAppClient(options) {
1429
1822
  export function createClient(options) {
1430
1823
  return new AwesomateClient(options);
1431
1824
  }
1825
+ const JWKS_TTL_MS = 5 * 60_000;
1826
+ const jwksCache = new Map();
1827
+ function b64urlBytes(s) {
1828
+ const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (s.length % 4)) % 4);
1829
+ const bin = atob(b64);
1830
+ const out = new Uint8Array(bin.length);
1831
+ for (let i = 0; i < bin.length; i++)
1832
+ out[i] = bin.charCodeAt(i);
1833
+ return out;
1834
+ }
1835
+ async function subtleCrypto() {
1836
+ const subtle = globalThis.crypto?.subtle;
1837
+ if (subtle)
1838
+ return subtle;
1839
+ // Node 18 has Web Crypto without the global; a bundler never sees this specifier.
1840
+ const spec = 'node:crypto';
1841
+ return (await import(spec)).webcrypto.subtle;
1842
+ }
1843
+ /** A token naming a key we lack refetches at most this often, so made-up key ids cannot make every request fetch. */
1844
+ const JWKS_REFETCH_MS = 30_000;
1845
+ async function hubKeys(url, doFetch, fresh) {
1846
+ const hit = jwksCache.get(url);
1847
+ const age = hit ? Date.now() - hit.at : Infinity;
1848
+ if (hit && (fresh ? age < JWKS_REFETCH_MS : age < JWKS_TTL_MS))
1849
+ return hit.keys;
1850
+ let res;
1851
+ try {
1852
+ res = await doFetch(url, { headers: { accept: 'application/json' } });
1853
+ }
1854
+ catch (err) {
1855
+ throw new AwesomateError('unavailable', `The hub's keys could not be read: ${err.message}`, 0);
1856
+ }
1857
+ if (!res.ok)
1858
+ throw new AwesomateError('unavailable', `The hub's keys could not be read (${res.status}).`, res.status);
1859
+ let keys;
1860
+ try {
1861
+ keys = (await res.json()).keys ?? [];
1862
+ }
1863
+ catch {
1864
+ throw new AwesomateError('unavailable', 'The hub\'s keys could not be read (not JSON).', res.status);
1865
+ }
1866
+ jwksCache.set(url, { at: Date.now(), keys });
1867
+ return keys;
1868
+ }
1869
+ /**
1870
+ * Verify an access token one of the account's apps sent your own server (from
1871
+ * app.auth.accessToken()), against the hub's public keys: ES256, signed by the hub, for this app,
1872
+ * not expired. Answers its claims; anything else rejects with code `unauthenticated`. The keys
1873
+ * are fetched from `<baseUrl>/api/sdk/v1/jwks.json` and kept for five minutes. Treat `role` as a
1874
+ * hint: the hub reads the person's current role on every call it answers.
1875
+ *
1876
+ * @example
1877
+ * import { verifyAppToken } from '@awesomate/sdk';
1878
+ *
1879
+ * const accessToken = 'the token your page sent, from app.auth.accessToken()';
1880
+ * const claims = await verifyAppToken(accessToken, { appId: 'app_your_app_id' });
1881
+ * console.log(claims.sub, claims.role);
1882
+ */
1883
+ export async function verifyAppToken(token, options) {
1884
+ const bad = (why) => { throw new AwesomateError('unauthenticated', why, 401); };
1885
+ if (!options?.appId)
1886
+ throw new Error('verifyAppToken needs the app\'s id (app_...): a token is only good for the app it was made for.');
1887
+ const parts = typeof token === 'string' ? token.split('.') : [];
1888
+ if (parts.length !== 3 || token.length > 4096)
1889
+ bad('That is not a sign-in token.');
1890
+ let header;
1891
+ let claims;
1892
+ try {
1893
+ const text = (s) => new TextDecoder().decode(b64urlBytes(s));
1894
+ header = JSON.parse(text(parts[0]));
1895
+ claims = JSON.parse(text(parts[1]));
1896
+ }
1897
+ catch {
1898
+ return bad('That is not a sign-in token.');
1899
+ }
1900
+ if (header.alg !== 'ES256' || !header.kid)
1901
+ bad('That sign-in token was not signed by the hub.');
1902
+ const doFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
1903
+ if (!doFetch)
1904
+ throw new Error('No fetch available: use Node 18 or later, or pass fetch.');
1905
+ const url = `${(options.baseUrl ?? DEFAULT_BASE).replace(/\/+$/, '')}/api/sdk/v1/jwks.json`;
1906
+ let jwk = (await hubKeys(url, doFetch, false)).find((k) => k.kid === header.kid);
1907
+ // A key the hub has added since the keys were last read.
1908
+ if (!jwk)
1909
+ jwk = (await hubKeys(url, doFetch, true)).find((k) => k.kid === header.kid);
1910
+ if (!jwk)
1911
+ return bad('That sign-in was not issued by this hub, or its key has been retired.');
1912
+ const subtle = await subtleCrypto();
1913
+ 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']);
1914
+ const ok = await subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, b64urlBytes(parts[2]), new TextEncoder().encode(`${parts[0]}.${parts[1]}`));
1915
+ if (!ok)
1916
+ bad('That sign-in token is not valid.');
1917
+ if (claims.iss !== (options.issuer ?? 'https://hub.awesomate.ai'))
1918
+ bad('That sign-in token was not issued by this hub.');
1919
+ if (claims.aud !== `awesomate-app:${options.appId}` || claims.app !== options.appId)
1920
+ bad('That sign-in token is for a different app.');
1921
+ if (typeof claims.exp !== 'number' || claims.exp * 1000 <= Date.now())
1922
+ bad('That sign-in has expired. Refresh it or sign in again.');
1923
+ if (typeof claims.sub !== 'string' || !claims.sub)
1924
+ bad('That sign-in token is not valid.');
1925
+ return {
1926
+ sub: claims.sub,
1927
+ app: claims.app,
1928
+ role: typeof claims.role === 'string' ? claims.role : 'member',
1929
+ contact: typeof claims.contact === 'string' ? claims.contact : null,
1930
+ exp: claims.exp,
1931
+ iat: typeof claims.iat === 'number' ? claims.iat : null,
1932
+ };
1933
+ }
1432
1934
  function staffBooking(b) {
1433
1935
  return {
1434
1936
  bookingId: b.booking_id, status: b.status, service: b.service,
@@ -1482,9 +1984,8 @@ export class AwesomateBookingsClient {
1482
1984
  }
1483
1985
  const data = (await res.json().catch(() => ({})));
1484
1986
  if (!res.ok) {
1485
- const server = typeof data.code === 'string' ? data.code : undefined;
1486
- const message = typeof data.error === 'string' ? data.error : `Request failed (${res.status})`;
1487
- throw new AwesomateError(errorCode(res.status, server), message, res.status, typeof data.field === 'string' ? data.field : undefined, server, message);
1987
+ const err = hubError(res.status, data);
1988
+ throw new AwesomateError(err.code, err.message, err.status, err.field, err.serverCode, err.message, err);
1488
1989
  }
1489
1990
  return data;
1490
1991
  }