@awesomate/sdk 0.5.0 → 0.6.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
@@ -91,19 +91,26 @@ import { createAppClient } from '@awesomate/sdk';
91
91
 
92
92
  const app = createAppClient({ publishableKey: 'pk_...' });
93
93
 
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` });
94
+ // Render on every change of person: signed in, signed out, a new role.
95
+ app.auth.onChange((u) => render(u));
96
+ app.auth.onSignInError((err) => showMessage(err.message));
96
97
 
97
- // /signed-in: exchanges the link for a session and clears it from the address bar.
98
- const user = await app.auth.completeSignIn();
98
+ // The sign-in page: an email link, no password. The answer is the same for any address.
99
+ await app.auth.signInWithLink(email, { redirectTo: location.href });
99
100
 
100
101
  // Everywhere else: the same calls as the server client, as this person.
101
102
  const { rows } = await app.query('job', { orderBy: ['created_at', 'desc'] });
102
103
  await app.write('forum_post', { body: 'Booked for Tuesday' }, { links: { job: rows[0].id } });
103
- app.auth.onChange((u) => render(u));
104
104
  await app.auth.signOut();
105
105
  ```
106
106
 
107
+ The link signs them in by itself. In a browser the client takes the link from the address bar,
108
+ on load or when it arrives in a tab that is already open (only the part after `#` changes, so
109
+ the page does not reload), clears it at once and tells `onChange`. A link that has expired or
110
+ was already used goes to `onSignInError`. `await app.auth.completeSignIn()` still works and
111
+ returns the same sign-in, never a second exchange. Pass `handleSignInLinks: false` to take links
112
+ yourself. `onChange` hears about people, not tokens: the routine token refresh is silent.
113
+
107
114
  ### Lists that keep themselves current
108
115
 
109
116
  ```ts
@@ -122,7 +129,7 @@ Outside a browser, give it a WebSocket: `createAppClient({ publishableKey, WebSo
122
129
  The rules are kept in your own database, which applies them to every read and write, so a mistake
123
130
  in the app cannot show anyone more than their role allows. A disabled person is refused on their
124
131
  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
132
+ call `completeSignIn(url)` with the deep link that opened the app. Your own server can verify
126
133
  `await app.auth.accessToken()` against `https://hub.awesomate.ai/api/sdk/v1/jwks.json`.
127
134
 
128
135
  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.6.0";
26
26
  /** Augmented by the generated awesomate.d.ts, so each kind's rows are typed. */
27
27
  export interface Kinds {
28
28
  }
@@ -375,6 +375,12 @@ export interface AppClientOptions {
375
375
  storageKey?: string;
376
376
  /** For live(): the WebSocket class. Default: the browser's (and Node 22's) global WebSocket. */
377
377
  WebSocket?: WebSocketLike;
378
+ /**
379
+ * Default true in a browser: a sign-in link is taken up by itself, when the page loads with one
380
+ * and when one arrives in a tab already showing the page (only the #fragment changes then, and
381
+ * the page does not reload). Set false when another client on the page handles links.
382
+ */
383
+ handleSignInLinks?: boolean;
378
384
  }
379
385
  /** The part of the WebSocket API live() uses: the browser's, Node 22's, or the ws package's. */
380
386
  export interface WebSocketLike {
@@ -416,8 +422,13 @@ export declare class AwesomateAppClient {
416
422
  private session;
417
423
  private refreshing;
418
424
  private readonly listeners;
425
+ private readonly linkErrorListeners;
426
+ /** The sign-in link being (or last) exchanged, so the same link is never exchanged twice. */
427
+ private link;
419
428
  private socket;
420
429
  constructor(opts: AppClientOptions);
430
+ /** A sign-in link in the address bar, exchanged in the background; a failure goes to onSignInError. */
431
+ private takeLink;
421
432
  readonly auth: {
422
433
  /** Email a sign-in link. The answer is the same whether or not the address can sign in. */
423
434
  signInWithLink: (email: string, options: {
@@ -428,14 +439,21 @@ export declare class AwesomateAppClient {
428
439
  }>;
429
440
  /**
430
441
  * 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.
442
+ * opened (Capacitor). Returns the user, or null when the address carries no sign-in. The token
443
+ * is removed from the browser's address bar. In a browser this happens by itself (see
444
+ * handleSignInLinks); calling it as well is safe, and returns the same sign-in, never a second.
433
445
  */
434
446
  completeSignIn: (url?: string) => Promise<AppUser | null>;
435
447
  /** The signed-in user, or null. */
436
448
  user: () => Promise<AppUser | null>;
437
- /** Called with the user on sign-in and null on sign-out (including a refresh that was refused). */
449
+ /**
450
+ * Called with the user when someone signs in (or their details change: a new role, a linked
451
+ * contact) and with null on sign-out, including a refresh that was refused. A routine token
452
+ * refresh does not call it.
453
+ */
438
454
  onChange: (listener: (user: AppUser | null) => void) => (() => void);
455
+ /** Called when a sign-in link could not be used (expired, already used, not this app's). */
456
+ onSignInError: (listener: (err: AwesomateError) => void) => (() => void);
439
457
  signOut: () => Promise<void>;
440
458
  /** A current access token, for the app's own server to verify against the JWKS. */
441
459
  accessToken: () => Promise<string | null>;
@@ -443,6 +461,7 @@ export declare class AwesomateAppClient {
443
461
  private request;
444
462
  private read;
445
463
  private load;
464
+ /** signedIn: a sign-in, always announced; otherwise a refresh, announced only when the person changed. */
446
465
  private save;
447
466
  private clear;
448
467
  /** 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.6.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 {
@@ -233,6 +233,9 @@ export class AwesomateAppClient {
233
233
  session;
234
234
  refreshing = null;
235
235
  listeners = new Set();
236
+ linkErrorListeners = new Set();
237
+ /** The sign-in link being (or last) exchanged, so the same link is never exchanged twice. */
238
+ link = null;
236
239
  socket = null;
237
240
  constructor(opts) {
238
241
  this.opts = opts;
@@ -246,32 +249,78 @@ export class AwesomateAppClient {
246
249
  throw new Error('No fetch available.');
247
250
  this.storage = opts.storage ?? browserStorage();
248
251
  this.key = opts.storageKey ?? `awesomate.session.${opts.publishableKey.slice(-10)}`;
252
+ const w = globalThis.window;
253
+ if (opts.handleSignInLinks !== false && w && typeof location !== 'undefined') {
254
+ this.takeLink();
255
+ w.addEventListener?.('hashchange', () => this.takeLink());
256
+ }
257
+ }
258
+ /** A sign-in link in the address bar, exchanged in the background; a failure goes to onSignInError. */
259
+ takeLink() {
260
+ if (signInTokenFrom(location.href))
261
+ void this.auth.completeSignIn().catch(() => undefined);
249
262
  }
250
263
  auth = {
251
264
  /** Email a sign-in link. The answer is the same whether or not the address can sign in. */
252
265
  signInWithLink: (email, options) => this.request('POST', '/auth/magic-link', { email, redirect_to: options.redirectTo }),
253
266
  /**
254
267
  * 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.
268
+ * opened (Capacitor). Returns the user, or null when the address carries no sign-in. The token
269
+ * is removed from the browser's address bar. In a browser this happens by itself (see
270
+ * handleSignInLinks); calling it as well is safe, and returns the same sign-in, never a second.
257
271
  */
258
- completeSignIn: async (url) => {
272
+ completeSignIn: (url) => {
259
273
  const here = typeof location !== 'undefined' ? location.href : '';
260
274
  const token = signInTokenFrom(url ?? here);
275
+ // The client may already have read the link from the address bar and cleared it.
261
276
  if (!token)
262
- return null;
277
+ return this.link && !this.link.settled ? this.link.promise : Promise.resolve(null);
278
+ // Before anything else: a reload must never find a link that was already used.
263
279
  if (!url && typeof history !== 'undefined' && typeof location !== 'undefined') {
264
280
  history.replaceState(history.state, '', location.pathname + location.search);
265
281
  }
266
- return (await this.save(await this.request('POST', '/auth/verify', { token }))).user;
282
+ // The same link again (a second click of the email) is the same sign-in, until a sign-out.
283
+ if (this.link?.token === token)
284
+ return this.link.promise;
285
+ const entry = { token, settled: false };
286
+ entry.promise = this.request('POST', '/auth/verify', { token }).then(async (r) => {
287
+ // Signed out, or another link taken, while this one was at the hub: it is not a sign-in any more.
288
+ if (this.link !== entry) {
289
+ void this.request('POST', '/auth/sign-out', { refresh_token: r.refresh_token }).catch(() => undefined);
290
+ return null;
291
+ }
292
+ return (await this.save(r, true)).user;
293
+ });
294
+ entry.promise.then(() => { entry.settled = true; }, (err) => {
295
+ entry.settled = true;
296
+ // Abandoned (a sign-out, or a newer link took over): nobody is waiting on this one any more.
297
+ if (this.link !== entry)
298
+ return;
299
+ // A failure is not remembered: the same link again goes back to the hub (a blip may have passed).
300
+ this.link = null;
301
+ const e = err instanceof AwesomateError ? err : new AwesomateError('unavailable', String(err?.message ?? err), 0);
302
+ for (const l of this.linkErrorListeners)
303
+ l(e);
304
+ });
305
+ this.link = entry;
306
+ return entry.promise;
267
307
  },
268
308
  /** The signed-in user, or null. */
269
309
  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). */
310
+ /**
311
+ * Called with the user when someone signs in (or their details change: a new role, a linked
312
+ * contact) and with null on sign-out, including a refresh that was refused. A routine token
313
+ * refresh does not call it.
314
+ */
271
315
  onChange: (listener) => {
272
316
  this.listeners.add(listener);
273
317
  return () => { this.listeners.delete(listener); };
274
318
  },
319
+ /** Called when a sign-in link could not be used (expired, already used, not this app's). */
320
+ onSignInError: (listener) => {
321
+ this.linkErrorListeners.add(listener);
322
+ return () => { this.linkErrorListeners.delete(listener); };
323
+ },
275
324
  signOut: async () => {
276
325
  const s = await this.load();
277
326
  await this.clear();
@@ -316,22 +365,35 @@ export class AwesomateAppClient {
316
365
  }
317
366
  }
318
367
  async load() {
368
+ if (this.session !== undefined)
369
+ return this.session;
370
+ const s = await this.read();
371
+ // A sign-in or sign-out that landed while storage was being read is newer than what was read.
319
372
  if (this.session === undefined)
320
- this.session = await this.read();
373
+ this.session = s;
321
374
  return this.session;
322
375
  }
323
- async save(r) {
376
+ /** signedIn: a sign-in, always announced; otherwise a refresh, announced only when the person changed. */
377
+ async save(r, signedIn = false) {
378
+ const before = this.session === undefined ? await this.read() : this.session;
324
379
  const s = { access_token: r.access_token, refresh_token: r.refresh_token, expires_at: Date.now() + r.expires_in * 1000, user: r.user };
325
380
  this.session = s;
326
381
  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);
382
+ // Listeners hear about people, not tokens: a refresh that returns the same user is silent.
383
+ if (signedIn || !before || JSON.stringify(before.user) !== JSON.stringify(s.user))
384
+ for (const l of this.listeners)
385
+ l(s.user);
386
+ // A token frame carries on as the same person; anyone else needs a connection of their own.
387
+ if (before && before.user.id !== s.user.id)
388
+ this.socket?.personChanged();
389
+ else
390
+ this.socket?.tokenChanged(s);
330
391
  return s;
331
392
  }
332
393
  async clear() {
333
394
  const had = this.session !== null;
334
395
  this.session = null;
396
+ this.link = null;
335
397
  await this.storage.removeItem(this.key);
336
398
  if (had)
337
399
  for (const l of this.listeners)
@@ -357,11 +419,17 @@ export class AwesomateAppClient {
357
419
  return stored;
358
420
  }
359
421
  try {
360
- return await this.save(await this.request('POST', '/auth/refresh', { refresh_token: s.refresh_token }));
422
+ const r = await this.request('POST', '/auth/refresh', { refresh_token: s.refresh_token });
423
+ // Someone signed in or out while this was at the hub: theirs is the session now.
424
+ if (this.session !== s)
425
+ return this.session ?? null;
426
+ return await this.save(r);
361
427
  }
362
428
  catch (err) {
363
429
  if (!(err instanceof AwesomateError) || err.code !== 'unauthenticated')
364
430
  throw err;
431
+ if (this.session !== s)
432
+ return this.session ?? null;
365
433
  const after = await this.read();
366
434
  if (after && after.refresh_token !== s.refresh_token) {
367
435
  this.session = after;
@@ -568,6 +636,30 @@ class LiveSocket {
568
636
  this.send({ type: 'token', token: s.access_token });
569
637
  this.scheduleRefresh(s);
570
638
  }
639
+ /**
640
+ * Someone else signed in: the open connection said hello as the person before, so start again as
641
+ * the new one. Every list stays and takes a fresh snapshot, which the database writes for them.
642
+ */
643
+ personChanged() {
644
+ const ws = this.ws;
645
+ this.ws = null;
646
+ this.ready = false;
647
+ this.stopped = false;
648
+ this.attempt = 0;
649
+ if (this.pinger)
650
+ clearInterval(this.pinger);
651
+ if (this.retry)
652
+ clearTimeout(this.retry);
653
+ this.pinger = this.retry = null;
654
+ if (ws && ws.readyState <= 1) {
655
+ try {
656
+ ws.send(JSON.stringify({ type: 'bye' }));
657
+ }
658
+ catch { /* closing */ }
659
+ ws.close(1000, 'bye');
660
+ }
661
+ void this.connect();
662
+ }
571
663
  /** Keep the socket's token fresh even when the app makes no other calls. */
572
664
  scheduleRefresh(s) {
573
665
  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.6.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",