@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 +13 -6
- package/dist/index.d.ts +24 -5
- package/dist/index.js +106 -14
- package/package.json +1 -1
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
|
-
//
|
|
95
|
-
|
|
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
|
-
//
|
|
98
|
-
|
|
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)`
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
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:
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 =
|
|
373
|
+
this.session = s;
|
|
321
374
|
return this.session;
|
|
322
375
|
}
|
|
323
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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