@awesomate/sdk 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -79,6 +79,26 @@ Every failure is an `AwesomateError` with a `code`: `unauthenticated`, `forbidde
79
79
  The token is your account's hosting token. Use it in a server, a script or a scheduled job, never
80
80
  in a browser. For a browser, sign your app's own users in (below).
81
81
 
82
+ ## Your app's server: a key of its own
83
+
84
+ An app's own server, or an n8n workflow, should not carry the account's token. Give it a server
85
+ key instead: Claude makes one with `awesomate_crm_apps` (`create_key`), read or write, for every
86
+ kind or only the ones it names. The key is shown once; put it in the server's environment.
87
+
88
+ ```ts
89
+ const db = createClient({ token: process.env.AWESOMATE_APP_KEY! }); // ak_...
90
+ const { rows } = await db.query('job', { where: { status: 'booked' } });
91
+ await db.write('job', { status: 'done' }, { id: rows[0].id });
92
+ await db.call('book_job', { customer, title: 'Gutter clean', status: 'booked' });
93
+ ```
94
+
95
+ A key reads and writes records and runs saved queries and recipes, only for its kinds (a recipe
96
+ that writes anything else is refused). It reads every attribute of an app kind, and the contact
97
+ list only as Claude does. Writes need Support Plus and above. It cannot change kinds, rules,
98
+ queries or recipes, and it never works from a browser. Revoking a key stops it at once; switching
99
+ the app off stops all of them. From n8n, send it as `Authorization: Bearer ak_...` to
100
+ `https://hub.awesomate.ai/api/sdk/v1/server/rows/query` (and `/call`, `/queries/<key>/run`).
101
+
82
102
  ## Your app's users sign in (Pro and above)
83
103
 
84
104
  Set the app up first (Claude Code: `awesomate_crm_apps`): its name, the origins it runs on, and
@@ -91,19 +111,26 @@ import { createAppClient } from '@awesomate/sdk';
91
111
 
92
112
  const app = createAppClient({ publishableKey: 'pk_...' });
93
113
 
94
- // The sign-in page: an email link, no password. The answer is the same for any address.
95
- await app.auth.signInWithLink(email, { redirectTo: `${location.origin}/signed-in` });
114
+ // Render on every change of person: signed in, signed out, a new role.
115
+ app.auth.onChange((u) => render(u));
116
+ app.auth.onSignInError((err) => showMessage(err.message));
96
117
 
97
- // /signed-in: exchanges the link for a session and clears it from the address bar.
98
- const user = await app.auth.completeSignIn();
118
+ // The sign-in page: an email link, no password. The answer is the same for any address.
119
+ await app.auth.signInWithLink(email, { redirectTo: location.href });
99
120
 
100
121
  // Everywhere else: the same calls as the server client, as this person.
101
122
  const { rows } = await app.query('job', { orderBy: ['created_at', 'desc'] });
102
123
  await app.write('forum_post', { body: 'Booked for Tuesday' }, { links: { job: rows[0].id } });
103
- app.auth.onChange((u) => render(u));
104
124
  await app.auth.signOut();
105
125
  ```
106
126
 
127
+ The link signs them in by itself. In a browser the client takes the link from the address bar,
128
+ on load or when it arrives in a tab that is already open (only the part after `#` changes, so
129
+ the page does not reload), clears it at once and tells `onChange`. A link that has expired or
130
+ was already used goes to `onSignInError`. `await app.auth.completeSignIn()` still works and
131
+ returns the same sign-in, never a second exchange. Pass `handleSignInLinks: false` to take links
132
+ yourself. `onChange` hears about people, not tokens: the routine token refresh is silent.
133
+
107
134
  ### Lists that keep themselves current
108
135
 
109
136
  ```ts
@@ -122,7 +149,7 @@ Outside a browser, give it a WebSocket: `createAppClient({ publishableKey, WebSo
122
149
  The rules are kept in your own database, which applies them to every read and write, so a mistake
123
150
  in the app cannot show anyone more than their role allows. A disabled person is refused on their
124
151
  next call. Sessions are kept in `localStorage`; in a Capacitor app pass `storage` (Preferences) and
125
- call `completeSignIn(url)` from the deep link that opened the app. Your own server can verify
152
+ call `completeSignIn(url)` with the deep link that opened the app. Your own server can verify
126
153
  `await app.auth.accessToken()` against `https://hub.awesomate.ai/api/sdk/v1/jwks.json`.
127
154
 
128
155
  MIT licence.
package/dist/index.d.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * access rules let them (Pro and above):
14
14
  * const app = createAppClient({ publishableKey: 'pk_...' });
15
15
  * await app.auth.signInWithLink(email, { redirectTo: `${location.origin}/signed-in` });
16
- * await app.auth.completeSignIn(); // on /signed-in
16
+ * app.auth.onChange((user) => render(user)); // the link signs them in by itself
17
17
  * const { rows } = await app.query('job', {}); // their jobs only
18
18
  * const stop = app.live('job', {}, (rows) => render(rows)); // and kept current as they change
19
19
  *
@@ -22,7 +22,7 @@
22
22
  * queries works on every plan; writing app data (kinds, records, saved queries, recipes) needs
23
23
  * crm:write, Support Plus and above.
24
24
  */
25
- export declare const VERSION = "0.5.0";
25
+ export declare const VERSION = "0.7.0";
26
26
  /** Augmented by the generated awesomate.d.ts, so each kind's rows are typed. */
27
27
  export interface Kinds {
28
28
  }
@@ -143,7 +143,11 @@ export declare class AwesomateError extends Error {
143
143
  serverCode?: string | undefined);
144
144
  }
145
145
  export interface ClientOptions {
146
- /** The account's hosting token (amt_pat_...) with crm:read. */
146
+ /**
147
+ * The account's hosting token (amt_pat_...) with crm:read, or an app's server key (ak_...) from
148
+ * the account's apps. A key reads and writes records and runs saved queries and recipes, for the
149
+ * kinds it was made for; changing kinds, queries or recipes needs the account's token.
150
+ */
147
151
  token: string;
148
152
  baseUrl?: string;
149
153
  fetch?: typeof fetch;
@@ -152,6 +156,8 @@ export declare class AwesomateClient {
152
156
  private readonly opts;
153
157
  private readonly base;
154
158
  private readonly doFetch;
159
+ /** An app key talks to its own routes, which carry only what a key may do. */
160
+ private readonly appKey;
155
161
  constructor(opts: ClientOptions);
156
162
  private request;
157
163
  /** The kinds this token can read, their columns, operators and examples. */
@@ -375,6 +381,12 @@ export interface AppClientOptions {
375
381
  storageKey?: string;
376
382
  /** For live(): the WebSocket class. Default: the browser's (and Node 22's) global WebSocket. */
377
383
  WebSocket?: WebSocketLike;
384
+ /**
385
+ * Default true in a browser: a sign-in link is taken up by itself, when the page loads with one
386
+ * and when one arrives in a tab already showing the page (only the #fragment changes then, and
387
+ * the page does not reload). Set false when another client on the page handles links.
388
+ */
389
+ handleSignInLinks?: boolean;
378
390
  }
379
391
  /** The part of the WebSocket API live() uses: the browser's, Node 22's, or the ws package's. */
380
392
  export interface WebSocketLike {
@@ -416,8 +428,13 @@ export declare class AwesomateAppClient {
416
428
  private session;
417
429
  private refreshing;
418
430
  private readonly listeners;
431
+ private readonly linkErrorListeners;
432
+ /** The sign-in link being (or last) exchanged, so the same link is never exchanged twice. */
433
+ private link;
419
434
  private socket;
420
435
  constructor(opts: AppClientOptions);
436
+ /** A sign-in link in the address bar, exchanged in the background; a failure goes to onSignInError. */
437
+ private takeLink;
421
438
  readonly auth: {
422
439
  /** Email a sign-in link. The answer is the same whether or not the address can sign in. */
423
440
  signInWithLink: (email: string, options: {
@@ -428,14 +445,21 @@ export declare class AwesomateAppClient {
428
445
  }>;
429
446
  /**
430
447
  * Finish signing in from the link: the current page's address by default, or a URL a deep link
431
- * opened (Capacitor). Returns the user, or null when the address carries no sign-in. The
432
- * token is removed from the browser's address bar.
448
+ * opened (Capacitor). Returns the user, or null when the address carries no sign-in. The token
449
+ * is removed from the browser's address bar. In a browser this happens by itself (see
450
+ * handleSignInLinks); calling it as well is safe, and returns the same sign-in, never a second.
433
451
  */
434
452
  completeSignIn: (url?: string) => Promise<AppUser | null>;
435
453
  /** The signed-in user, or null. */
436
454
  user: () => Promise<AppUser | null>;
437
- /** Called with the user on sign-in and null on sign-out (including a refresh that was refused). */
455
+ /**
456
+ * Called with the user when someone signs in (or their details change: a new role, a linked
457
+ * contact) and with null on sign-out, including a refresh that was refused. A routine token
458
+ * refresh does not call it.
459
+ */
438
460
  onChange: (listener: (user: AppUser | null) => void) => (() => void);
461
+ /** Called when a sign-in link could not be used (expired, already used, not this app's). */
462
+ onSignInError: (listener: (err: AwesomateError) => void) => (() => void);
439
463
  signOut: () => Promise<void>;
440
464
  /** A current access token, for the app's own server to verify against the JWKS. */
441
465
  accessToken: () => Promise<string | null>;
@@ -443,6 +467,7 @@ export declare class AwesomateAppClient {
443
467
  private request;
444
468
  private read;
445
469
  private load;
470
+ /** signedIn: a sign-in, always announced; otherwise a refresh, announced only when the person changed. */
446
471
  private save;
447
472
  private clear;
448
473
  /** A session whose access token has at least half a minute left, refreshing if not. */
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * access rules let them (Pro and above):
14
14
  * const app = createAppClient({ publishableKey: 'pk_...' });
15
15
  * await app.auth.signInWithLink(email, { redirectTo: `${location.origin}/signed-in` });
16
- * await app.auth.completeSignIn(); // on /signed-in
16
+ * app.auth.onChange((user) => render(user)); // the link signs them in by itself
17
17
  * const { rows } = await app.query('job', {}); // their jobs only
18
18
  * const stop = app.live('job', {}, (rows) => render(rows)); // and kept current as they change
19
19
  *
@@ -22,7 +22,7 @@
22
22
  * queries works on every plan; writing app data (kinds, records, saved queries, recipes) needs
23
23
  * crm:write, Support Plus and above.
24
24
  */
25
- export const VERSION = '0.5.0';
25
+ export const VERSION = '0.7.0';
26
26
  const DEFAULT_BASE = 'https://hub.awesomate.ai';
27
27
  const ERROR_CODES = ['unauthenticated', 'forbidden', 'not_found', 'validation', 'consent_blocked', 'rate_limited', 'conflict', 'unavailable'];
28
28
  export class AwesomateError extends Error {
@@ -58,20 +58,37 @@ function errorCode(status, server) {
58
58
  return 'not_found';
59
59
  return 'unavailable';
60
60
  }
61
+ const ACCOUNT = '/api/my-crm/v1';
62
+ const KEY_ROUTES = [
63
+ ['GET', /^\/rows\/schema$/], ['POST', /^\/rows\/query$/], ['GET', /^\/rows\/[^/]+\/[^/?]+(\?.*)?$/], ['POST', /^\/call$/],
64
+ ['GET', /^\/queries$/], ['POST', /^\/queries\/[^/]+\/run$/], ['GET', /^\/recipes$/],
65
+ ];
66
+ /** The same call on an app key's routes, or a clear refusal for what only the account may do. */
67
+ function keyPath(method, path) {
68
+ const rest = path.startsWith(ACCOUNT) ? path.slice(ACCOUNT.length) : path;
69
+ if (KEY_ROUTES.some(([m, re]) => m === method && re.test(rest)))
70
+ return `/api/sdk/v1/server${rest}`;
71
+ throw new AwesomateError('forbidden', 'An app key reads and writes records and runs saved queries and recipes. This needs the account\'s token.', 0);
72
+ }
61
73
  export class AwesomateClient {
62
74
  opts;
63
75
  base;
64
76
  doFetch;
77
+ /** An app key talks to its own routes, which carry only what a key may do. */
78
+ appKey;
65
79
  constructor(opts) {
66
80
  this.opts = opts;
67
81
  if (!opts?.token)
68
- throw new Error('createClient needs a token (the account\'s hosting token with crm:read).');
82
+ throw new Error('createClient needs a token: the account\'s hosting token, or an app\'s server key (ak_...).');
83
+ this.appKey = opts.token.startsWith('ak_');
69
84
  this.base = (opts.baseUrl ?? DEFAULT_BASE).replace(/\/+$/, '');
70
85
  this.doFetch = opts.fetch ?? globalThis.fetch;
71
86
  if (!this.doFetch)
72
87
  throw new Error('No fetch available: use Node 18 or later, or pass fetch.');
73
88
  }
74
89
  async request(method, path, body, retry = true) {
90
+ if (this.appKey)
91
+ path = keyPath(method, path);
75
92
  const res = await this.doFetch(`${this.base}${path}`, {
76
93
  method,
77
94
  headers: {
@@ -233,6 +250,9 @@ export class AwesomateAppClient {
233
250
  session;
234
251
  refreshing = null;
235
252
  listeners = new Set();
253
+ linkErrorListeners = new Set();
254
+ /** The sign-in link being (or last) exchanged, so the same link is never exchanged twice. */
255
+ link = null;
236
256
  socket = null;
237
257
  constructor(opts) {
238
258
  this.opts = opts;
@@ -246,32 +266,78 @@ export class AwesomateAppClient {
246
266
  throw new Error('No fetch available.');
247
267
  this.storage = opts.storage ?? browserStorage();
248
268
  this.key = opts.storageKey ?? `awesomate.session.${opts.publishableKey.slice(-10)}`;
269
+ const w = globalThis.window;
270
+ if (opts.handleSignInLinks !== false && w && typeof location !== 'undefined') {
271
+ this.takeLink();
272
+ w.addEventListener?.('hashchange', () => this.takeLink());
273
+ }
274
+ }
275
+ /** A sign-in link in the address bar, exchanged in the background; a failure goes to onSignInError. */
276
+ takeLink() {
277
+ if (signInTokenFrom(location.href))
278
+ void this.auth.completeSignIn().catch(() => undefined);
249
279
  }
250
280
  auth = {
251
281
  /** Email a sign-in link. The answer is the same whether or not the address can sign in. */
252
282
  signInWithLink: (email, options) => this.request('POST', '/auth/magic-link', { email, redirect_to: options.redirectTo }),
253
283
  /**
254
284
  * Finish signing in from the link: the current page's address by default, or a URL a deep link
255
- * opened (Capacitor). Returns the user, or null when the address carries no sign-in. The
256
- * token is removed from the browser's address bar.
285
+ * opened (Capacitor). Returns the user, or null when the address carries no sign-in. The token
286
+ * is removed from the browser's address bar. In a browser this happens by itself (see
287
+ * handleSignInLinks); calling it as well is safe, and returns the same sign-in, never a second.
257
288
  */
258
- completeSignIn: async (url) => {
289
+ completeSignIn: (url) => {
259
290
  const here = typeof location !== 'undefined' ? location.href : '';
260
291
  const token = signInTokenFrom(url ?? here);
292
+ // The client may already have read the link from the address bar and cleared it.
261
293
  if (!token)
262
- return null;
294
+ return this.link && !this.link.settled ? this.link.promise : Promise.resolve(null);
295
+ // Before anything else: a reload must never find a link that was already used.
263
296
  if (!url && typeof history !== 'undefined' && typeof location !== 'undefined') {
264
297
  history.replaceState(history.state, '', location.pathname + location.search);
265
298
  }
266
- return (await this.save(await this.request('POST', '/auth/verify', { token }))).user;
299
+ // The same link again (a second click of the email) is the same sign-in, until a sign-out.
300
+ if (this.link?.token === token)
301
+ return this.link.promise;
302
+ const entry = { token, settled: false };
303
+ entry.promise = this.request('POST', '/auth/verify', { token }).then(async (r) => {
304
+ // Signed out, or another link taken, while this one was at the hub: it is not a sign-in any more.
305
+ if (this.link !== entry) {
306
+ void this.request('POST', '/auth/sign-out', { refresh_token: r.refresh_token }).catch(() => undefined);
307
+ return null;
308
+ }
309
+ return (await this.save(r, true)).user;
310
+ });
311
+ entry.promise.then(() => { entry.settled = true; }, (err) => {
312
+ entry.settled = true;
313
+ // Abandoned (a sign-out, or a newer link took over): nobody is waiting on this one any more.
314
+ if (this.link !== entry)
315
+ return;
316
+ // A failure is not remembered: the same link again goes back to the hub (a blip may have passed).
317
+ this.link = null;
318
+ const e = err instanceof AwesomateError ? err : new AwesomateError('unavailable', String(err?.message ?? err), 0);
319
+ for (const l of this.linkErrorListeners)
320
+ l(e);
321
+ });
322
+ this.link = entry;
323
+ return entry.promise;
267
324
  },
268
325
  /** The signed-in user, or null. */
269
326
  user: async () => (await this.load())?.user ?? null,
270
- /** Called with the user on sign-in and null on sign-out (including a refresh that was refused). */
327
+ /**
328
+ * Called with the user when someone signs in (or their details change: a new role, a linked
329
+ * contact) and with null on sign-out, including a refresh that was refused. A routine token
330
+ * refresh does not call it.
331
+ */
271
332
  onChange: (listener) => {
272
333
  this.listeners.add(listener);
273
334
  return () => { this.listeners.delete(listener); };
274
335
  },
336
+ /** Called when a sign-in link could not be used (expired, already used, not this app's). */
337
+ onSignInError: (listener) => {
338
+ this.linkErrorListeners.add(listener);
339
+ return () => { this.linkErrorListeners.delete(listener); };
340
+ },
275
341
  signOut: async () => {
276
342
  const s = await this.load();
277
343
  await this.clear();
@@ -316,22 +382,35 @@ export class AwesomateAppClient {
316
382
  }
317
383
  }
318
384
  async load() {
385
+ if (this.session !== undefined)
386
+ return this.session;
387
+ const s = await this.read();
388
+ // A sign-in or sign-out that landed while storage was being read is newer than what was read.
319
389
  if (this.session === undefined)
320
- this.session = await this.read();
390
+ this.session = s;
321
391
  return this.session;
322
392
  }
323
- async save(r) {
393
+ /** signedIn: a sign-in, always announced; otherwise a refresh, announced only when the person changed. */
394
+ async save(r, signedIn = false) {
395
+ const before = this.session === undefined ? await this.read() : this.session;
324
396
  const s = { access_token: r.access_token, refresh_token: r.refresh_token, expires_at: Date.now() + r.expires_in * 1000, user: r.user };
325
397
  this.session = s;
326
398
  await this.storage.setItem(this.key, JSON.stringify(s));
327
- for (const l of this.listeners)
328
- l(s.user);
329
- this.socket?.tokenChanged(s);
399
+ // Listeners hear about people, not tokens: a refresh that returns the same user is silent.
400
+ if (signedIn || !before || JSON.stringify(before.user) !== JSON.stringify(s.user))
401
+ for (const l of this.listeners)
402
+ l(s.user);
403
+ // A token frame carries on as the same person; anyone else needs a connection of their own.
404
+ if (before && before.user.id !== s.user.id)
405
+ this.socket?.personChanged();
406
+ else
407
+ this.socket?.tokenChanged(s);
330
408
  return s;
331
409
  }
332
410
  async clear() {
333
411
  const had = this.session !== null;
334
412
  this.session = null;
413
+ this.link = null;
335
414
  await this.storage.removeItem(this.key);
336
415
  if (had)
337
416
  for (const l of this.listeners)
@@ -357,11 +436,17 @@ export class AwesomateAppClient {
357
436
  return stored;
358
437
  }
359
438
  try {
360
- return await this.save(await this.request('POST', '/auth/refresh', { refresh_token: s.refresh_token }));
439
+ const r = await this.request('POST', '/auth/refresh', { refresh_token: s.refresh_token });
440
+ // Someone signed in or out while this was at the hub: theirs is the session now.
441
+ if (this.session !== s)
442
+ return this.session ?? null;
443
+ return await this.save(r);
361
444
  }
362
445
  catch (err) {
363
446
  if (!(err instanceof AwesomateError) || err.code !== 'unauthenticated')
364
447
  throw err;
448
+ if (this.session !== s)
449
+ return this.session ?? null;
365
450
  const after = await this.read();
366
451
  if (after && after.refresh_token !== s.refresh_token) {
367
452
  this.session = after;
@@ -568,6 +653,30 @@ class LiveSocket {
568
653
  this.send({ type: 'token', token: s.access_token });
569
654
  this.scheduleRefresh(s);
570
655
  }
656
+ /**
657
+ * Someone else signed in: the open connection said hello as the person before, so start again as
658
+ * the new one. Every list stays and takes a fresh snapshot, which the database writes for them.
659
+ */
660
+ personChanged() {
661
+ const ws = this.ws;
662
+ this.ws = null;
663
+ this.ready = false;
664
+ this.stopped = false;
665
+ this.attempt = 0;
666
+ if (this.pinger)
667
+ clearInterval(this.pinger);
668
+ if (this.retry)
669
+ clearTimeout(this.retry);
670
+ this.pinger = this.retry = null;
671
+ if (ws && ws.readyState <= 1) {
672
+ try {
673
+ ws.send(JSON.stringify({ type: 'bye' }));
674
+ }
675
+ catch { /* closing */ }
676
+ ws.close(1000, 'bye');
677
+ }
678
+ void this.connect();
679
+ }
571
680
  /** Keep the socket's token fresh even when the app makes no other calls. */
572
681
  scheduleRefresh(s) {
573
682
  if (this.refresher)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awesomate/sdk",
3
- "version": "0.5.0",
3
+ "version": "0.7.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",