@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 +33 -6
- package/dist/index.d.ts +31 -6
- package/dist/index.js +124 -15
- package/package.json +1 -1
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
|
-
//
|
|
95
|
-
|
|
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
|
-
//
|
|
98
|
-
|
|
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)`
|
|
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
|
-
*
|
|
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.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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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.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
|
|
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
|
-
*
|
|
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:
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 =
|
|
390
|
+
this.session = s;
|
|
321
391
|
return this.session;
|
|
322
392
|
}
|
|
323
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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