@awesomate/sdk 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -24,7 +24,7 @@
24
24
  * Docs: https://hub.awesomate.ai/docs/sdk/
25
25
  */
26
26
  /** This package's version, sent to the hub with every server call. */
27
- export declare const VERSION = "0.15.0";
27
+ export declare const VERSION = "0.16.0";
28
28
  /** Augmented by the generated awesomate.d.ts, so each kind's rows are typed. */
29
29
  export interface Kinds {
30
30
  }
@@ -157,6 +157,38 @@ export interface Page<R> {
157
157
  /** When the hub read the rows. */
158
158
  asAt: string;
159
159
  }
160
+ /** A file or folder in the account's Files (the folders on its automation account: public/, private/, temp/). */
161
+ export interface FileEntry {
162
+ /** Relative to the files folder, starting with its top folder: public/images/logo.png. */
163
+ path: string;
164
+ name: string;
165
+ type: 'file' | 'dir';
166
+ /** Bytes; 0 for a folder. */
167
+ size: number;
168
+ /** ISO time it last changed. */
169
+ modified: string;
170
+ /** For a file in public/: the address anyone with it can open, with no login. Otherwise null. */
171
+ public_url: string | null;
172
+ }
173
+ /** How much of the plan's Files space is used. */
174
+ export interface FilesUsage {
175
+ used_bytes: number;
176
+ limit_bytes: number;
177
+ files: number;
178
+ /** The measure stopped early on a very large tree: used_bytes is at least this much. */
179
+ partial: boolean;
180
+ measured_at: string;
181
+ }
182
+ /** A file uploaded from an app. Keep `handle` in a record (a kind's file attribute); it opens the file again. */
183
+ export interface AppFile {
184
+ handle: string;
185
+ name: string;
186
+ size: number;
187
+ /** Only when the app's uploads are public. */
188
+ public_url: string | null;
189
+ }
190
+ /** What a file can be sent as. */
191
+ export type FileBody = Blob | ArrayBuffer | Uint8Array | string;
160
192
  declare const ERROR_CODES: readonly ["unauthenticated", "forbidden", "not_found", "validation", "consent_blocked", "rate_limited", "conflict", "unavailable"];
161
193
  /**
162
194
  * Why a call failed, one of a fixed list: unauthenticated, forbidden, not_found, validation,
@@ -215,6 +247,56 @@ export declare class AwesomateClient {
215
247
  private readonly appKey;
216
248
  constructor(opts: ClientOptions);
217
249
  private request;
250
+ /** A call whose body or answer is a file, not JSON. Answers the Response when it succeeded. */
251
+ private raw;
252
+ private filesCall;
253
+ /**
254
+ * The account's Files: the folders on its automation account that its n8n workflows read and
255
+ * write (public/, private/, temp/). A file in public/ has a public_url anyone with it can open;
256
+ * the others never do. Needs the account's token with files:read (reading) or files:write
257
+ * (changing), on a plan with Files, after the owner has switched on "Open your automation
258
+ * account's file folders" in Settings, Privacy.
259
+ *
260
+ * @example
261
+ * const logo = await db.files.upload('public/brand/logo.png', await readFile('logo.png'));
262
+ * console.log(logo.public_url);
263
+ */
264
+ readonly files: {
265
+ /** One folder; '' (the default) lists the top folders. */
266
+ list: (path?: string) => Promise<FileEntry[]>;
267
+ /** Files by part of their name (* and ? are wildcards), across the folders or in one. At most 200: `truncated` says there were more. */
268
+ search: (options?: {
269
+ q?: string;
270
+ top?: "public" | "private" | "temp";
271
+ extensions?: string[];
272
+ }) => Promise<{
273
+ results: FileEntry[];
274
+ truncated: boolean;
275
+ }>;
276
+ /** Space used against the plan's Files space. Measured, and up to ten minutes old. */
277
+ usage: () => Promise<FilesUsage>;
278
+ /**
279
+ * Write a file (up to 50 MB). Refuses to replace one already there unless `overwrite`. A full
280
+ * Files space answers serverCode files_storage_full.
281
+ */
282
+ upload: (path: string, data: FileBody, options?: {
283
+ overwrite?: boolean;
284
+ }) => Promise<FileEntry>;
285
+ /** A file's contents. */
286
+ download: (path: string) => Promise<Blob>;
287
+ /** Make a folder, and any above it. */
288
+ mkdir: (path: string) => Promise<FileEntry>;
289
+ /** Move or rename. Anything using the old path or address needs the new one. */
290
+ move: (from: string, to: string) => Promise<FileEntry>;
291
+ /** Move a file from private/ or temp/ to the same place under public/, and answer its public_url. Anyone with the address can open it. */
292
+ makePublic: (path: string) => Promise<FileEntry>;
293
+ /** Move a file out of public/ into private/. Its public address stops serving it, though Cloudflare can keep showing the last copy for up to 4 hours. */
294
+ makePrivate: (path: string) => Promise<FileEntry>;
295
+ /** Move to the trash. Not erased, and no longer counted toward the Files space. */
296
+ remove: (path: string) => Promise<{
297
+ trashPath: string;
298
+ }>;
299
+ };
218
300
  /** The kinds this token can read, their columns, operators and examples. */
219
301
  schema(): Promise<{
220
302
  kinds: Array<{
@@ -871,6 +953,34 @@ export declare class AwesomateAppClient {
871
953
  ids?: string[];
872
954
  limit?: number;
873
955
  }): Promise<Customer[]>;
956
+ /** Runs a call with the current access token, refreshing it once when the hub says it expired. */
957
+ private authed;
958
+ private rawData;
959
+ /**
960
+ * Files people attach in this app, such as a photo of a job or a signed form, kept in the
961
+ * account's Files. Off until the account switches it on for the app (private, or public with a
962
+ * public address per file): check mode() before showing an upload button. Keep the handle an
963
+ * upload answers in a record's file attribute; anyone signed in to this app who can read that
964
+ * record can open the file with download().
965
+ *
966
+ * @example
967
+ * const file = await app.files.upload(input.files[0]);
968
+ * await app.write('message', { body: 'Photo of the leak', attachment: file.handle }, { links: { job: jobId } });
969
+ */
970
+ readonly files: {
971
+ /** Whether this app takes uploads: 'off', 'private' or 'public'. */
972
+ mode: () => Promise<"off" | "private" | "public">;
973
+ /**
974
+ * Upload one file, up to 10 MB. The name comes from a File, or pass one. A refusal's
975
+ * serverCode says why: uploads_off, too_large, type_not_allowed (public uploads take no web
976
+ * pages or scripts), files_storage_full; personMessage is the sentence to show.
977
+ */
978
+ upload: (file: Blob, options?: {
979
+ name?: string;
980
+ }) => Promise<AppFile>;
981
+ /** The file a handle names, for someone signed in to this app. */
982
+ download: (handle: string) => Promise<Blob>;
983
+ };
874
984
  /**
875
985
  * The agent this app talks with by voice, or null when the account has not picked one. Pass it
876
986
  * to AwesomateAgent.mount as agentId; the hub decides which agent answers, never the page.
package/dist/index.js CHANGED
@@ -24,7 +24,7 @@
24
24
  * Docs: https://hub.awesomate.ai/docs/sdk/
25
25
  */
26
26
  /** This package's version, sent to the hub with every server call. */
27
- export const VERSION = '0.15.0';
27
+ export const VERSION = '0.16.0';
28
28
  const DEFAULT_BASE = 'https://hub.awesomate.ai';
29
29
  const ERROR_CODES = ['unauthenticated', 'forbidden', 'not_found', 'validation', 'consent_blocked', 'rate_limited', 'conflict', 'unavailable'];
30
30
  /**
@@ -57,13 +57,17 @@ export class AwesomateError extends Error {
57
57
  this.name = 'AwesomateError';
58
58
  }
59
59
  }
60
+ const CONSENT_CODES = new Set(['consent_blocked', 'consent_denied', 'files_consent_off']);
60
61
  function errorCode(status, server) {
61
62
  if (server === 'validation' || server === 'invalid' || status === 422)
62
63
  return 'validation';
64
+ // A file the hub will not take as sent: too big, no length, or a type it refuses.
65
+ if (status === 411 || status === 413 || status === 415)
66
+ return 'validation';
63
67
  if (status === 401)
64
68
  return 'unauthenticated';
65
69
  if (status === 403)
66
- return server === 'consent_blocked' ? 'consent_blocked' : 'forbidden';
70
+ return server && CONSENT_CODES.has(server) ? 'consent_blocked' : 'forbidden';
67
71
  if (status === 429)
68
72
  return 'rate_limited';
69
73
  if (server === 'conflict')
@@ -133,6 +137,99 @@ export class AwesomateClient {
133
137
  const message = typeof json.error === 'string' ? json.error : `The hub answered ${res.status}.`;
134
138
  throw new AwesomateError(code, message, res.status, typeof json.field === 'string' ? json.field : undefined, server);
135
139
  }
140
+ /** A call whose body or answer is a file, not JSON. Answers the Response when it succeeded. */
141
+ async raw(method, path, body) {
142
+ if (this.appKey) {
143
+ throw new AwesomateError('forbidden', 'Files needs the account\'s token (amt_pat_... with files:read or files:write), not an app key.', 0);
144
+ }
145
+ const res = await this.doFetch(`${this.base}${path}`, {
146
+ method,
147
+ headers: {
148
+ authorization: `Bearer ${this.opts.token}`,
149
+ 'user-agent': `@awesomate/sdk/${VERSION}`,
150
+ ...(body === undefined ? {} : { 'content-type': 'application/octet-stream' }),
151
+ },
152
+ body: body,
153
+ });
154
+ if (res.ok)
155
+ return res;
156
+ const text = await res.text();
157
+ let json = {};
158
+ try {
159
+ json = text ? JSON.parse(text) : {};
160
+ }
161
+ 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);
165
+ }
166
+ filesCall(method, path, body) {
167
+ if (this.appKey) {
168
+ return Promise.reject(new AwesomateError('forbidden', 'Files needs the account\'s token (amt_pat_... with files:read or files:write), not an app key.', 0));
169
+ }
170
+ return this.request(method, path, body, false);
171
+ }
172
+ /**
173
+ * The account's Files: the folders on its automation account that its n8n workflows read and
174
+ * write (public/, private/, temp/). A file in public/ has a public_url anyone with it can open;
175
+ * the others never do. Needs the account's token with files:read (reading) or files:write
176
+ * (changing), on a plan with Files, after the owner has switched on "Open your automation
177
+ * account's file folders" in Settings, Privacy.
178
+ *
179
+ * @example
180
+ * const logo = await db.files.upload('public/brand/logo.png', await readFile('logo.png'));
181
+ * console.log(logo.public_url);
182
+ */
183
+ files = {
184
+ /** One folder; '' (the default) lists the top folders. */
185
+ list: async (path = '') => (await this.filesCall('GET', `/api/files?path=${encodeURIComponent(path)}`)).entries,
186
+ /** Files by part of their name (* and ? are wildcards), across the folders or in one. At most 200: `truncated` says there were more. */
187
+ search: async (options = {}) => {
188
+ const qs = new URLSearchParams({ q: options.q ?? '' });
189
+ if (options.top)
190
+ qs.set('top', options.top);
191
+ if (options.extensions?.length)
192
+ qs.set('exts', options.extensions.join(','));
193
+ const r = await this.filesCall('GET', `/api/files/search?${qs}`);
194
+ return { results: r.results, truncated: r.truncated };
195
+ },
196
+ /** Space used against the plan's Files space. Measured, and up to ten minutes old. */
197
+ usage: () => this.filesCall('GET', '/api/files/usage'),
198
+ /**
199
+ * Write a file (up to 50 MB). Refuses to replace one already there unless `overwrite`. A full
200
+ * Files space answers serverCode files_storage_full.
201
+ */
202
+ upload: async (path, data, options = {}) => {
203
+ const qs = new URLSearchParams({ path });
204
+ if (options.overwrite)
205
+ qs.set('overwrite', '1');
206
+ return (await (await this.raw('POST', `/api/files?${qs}`, data)).json()).entry;
207
+ },
208
+ /** A file's contents. */
209
+ download: async (path) => (await this.raw('GET', `/api/files/stream?${new URLSearchParams({ path, disposition: 'attachment' })}`)).blob(),
210
+ /** Make a folder, and any above it. */
211
+ mkdir: async (path) => (await this.filesCall('POST', '/api/files/mkdir', { path })).entry,
212
+ /** Move or rename. Anything using the old path or address needs the new one. */
213
+ move: async (from, to) => (await this.filesCall('POST', '/api/files/move', { from, to })).entry,
214
+ /** Move a file from private/ or temp/ to the same place under public/, and answer its public_url. Anyone with the address can open it. */
215
+ makePublic: async (path) => {
216
+ const [top, ...rest] = path.replace(/^\/+/, '').split('/');
217
+ if (top === 'public')
218
+ throw new AwesomateError('validation', `${path} is already public.`, 0, 'path');
219
+ if (!rest.length)
220
+ throw new AwesomateError('validation', 'Name a file inside the folder.', 0, 'path');
221
+ return this.files.move(path, `public/${rest.join('/')}`);
222
+ },
223
+ /** Move a file out of public/ into private/. Its public address stops serving it, though Cloudflare can keep showing the last copy for up to 4 hours. */
224
+ makePrivate: async (path) => {
225
+ const [top, ...rest] = path.replace(/^\/+/, '').split('/');
226
+ if (top !== 'public' || !rest.length)
227
+ throw new AwesomateError('validation', `${path} is not a file in public/.`, 0, 'path');
228
+ return this.files.move(path, `private/${rest.join('/')}`);
229
+ },
230
+ /** Move to the trash. Not erased, and no longer counted toward the Files space. */
231
+ remove: async (path) => this.filesCall('DELETE', `/api/files?path=${encodeURIComponent(path)}`),
232
+ };
136
233
  /** The kinds this token can read, their columns, operators and examples. */
137
234
  schema() {
138
235
  return this.request('GET', '/api/my-crm/v1/rows/schema');
@@ -597,6 +694,71 @@ export class AwesomateAppClient {
597
694
  async lookupCustomers(options) {
598
695
  return (await this.data('POST', '/customers/lookup', options)).customers;
599
696
  }
697
+ /** Runs a call with the current access token, refreshing it once when the hub says it expired. */
698
+ async authed(fn) {
699
+ let s = await this.current();
700
+ if (!s)
701
+ throw new AwesomateError('unauthenticated', 'Sign in first.', 401);
702
+ try {
703
+ return await fn(s.access_token);
704
+ }
705
+ catch (err) {
706
+ if (!(err instanceof AwesomateError) || err.code !== 'unauthenticated')
707
+ throw err;
708
+ s = await this.refresh(s);
709
+ if (!s)
710
+ throw err;
711
+ return fn(s.access_token);
712
+ }
713
+ }
714
+ async rawData(method, path, token, body) {
715
+ const res = await this.doFetch(`${this.base}/api/sdk/v1${path}`, {
716
+ method,
717
+ headers: {
718
+ 'x-awesomate-key': this.opts.publishableKey,
719
+ authorization: `Bearer ${token}`,
720
+ ...(body === undefined ? {} : { 'content-type': 'application/octet-stream' }),
721
+ },
722
+ body: body,
723
+ });
724
+ if (res.ok)
725
+ return res;
726
+ const text = await res.text();
727
+ let json = {};
728
+ try {
729
+ json = text ? JSON.parse(text) : {};
730
+ }
731
+ catch { /* a proxy's error page */ }
732
+ const server = typeof json.code === 'string' ? json.code : undefined;
733
+ const message = typeof json.error === 'string' ? json.error : `The hub answered ${res.status}.`;
734
+ throw new AwesomateError(errorCode(res.status, server), message, res.status, undefined, server, message);
735
+ }
736
+ /**
737
+ * Files people attach in this app, such as a photo of a job or a signed form, kept in the
738
+ * account's Files. Off until the account switches it on for the app (private, or public with a
739
+ * public address per file): check mode() before showing an upload button. Keep the handle an
740
+ * upload answers in a record's file attribute; anyone signed in to this app who can read that
741
+ * record can open the file with download().
742
+ *
743
+ * @example
744
+ * const file = await app.files.upload(input.files[0]);
745
+ * await app.write('message', { body: 'Photo of the leak', attachment: file.handle }, { links: { job: jobId } });
746
+ */
747
+ files = {
748
+ /** Whether this app takes uploads: 'off', 'private' or 'public'. */
749
+ mode: async () => (await this.data('GET', '/me')).app.file_uploads ?? 'off',
750
+ /**
751
+ * Upload one file, up to 10 MB. The name comes from a File, or pass one. A refusal's
752
+ * serverCode says why: uploads_off, too_large, type_not_allowed (public uploads take no web
753
+ * pages or scripts), files_storage_full; personMessage is the sentence to show.
754
+ */
755
+ upload: async (file, options = {}) => {
756
+ const name = options.name ?? file.name ?? 'file';
757
+ return this.authed(async (token) => (await (await this.rawData('POST', `/files?name=${encodeURIComponent(name)}`, token, file)).json()).file);
758
+ },
759
+ /** The file a handle names, for someone signed in to this app. */
760
+ download: async (handle) => this.authed(async (token) => (await this.rawData('GET', `/files/${encodeURIComponent(handle)}`, token)).blob()),
761
+ };
600
762
  /**
601
763
  * The agent this app talks with by voice, or null when the account has not picked one. Pass it
602
764
  * to AwesomateAgent.mount as agentId; the hub decides which agent answers, never the page.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awesomate/sdk",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Your own Awesomate data from Node and the browser: query contacts and app data with generated types, and sign your app's own users in",
5
5
  "license": "MIT",
6
6
  "type": "module",