@awesomate/sdk 0.3.0 → 0.4.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
@@ -77,6 +77,37 @@ Every failure is an `AwesomateError` with a `code`: `unauthenticated`, `forbidde
77
77
  ## Keep the token on a server
78
78
 
79
79
  The token is your account's hosting token. Use it in a server, a script or a scheduled job, never
80
- in a browser. Sign-in for your own app's users comes in a later version.
80
+ in a browser. For a browser, sign your app's own users in (below).
81
+
82
+ ## Your app's users sign in (Pro and above)
83
+
84
+ Set the app up first (Claude Code: `awesomate_crm_apps`): its name, the origins it runs on, and
85
+ whether anyone may sign up or only people you add. That gives you a publishable key, which is not a
86
+ secret. Then say who may read and change each kind (`awesomate_crm_kinds`, `set_access`), for
87
+ example members read jobs whose customer is them and the memos on those jobs.
88
+
89
+ ```ts
90
+ import { createAppClient } from '@awesomate/sdk';
91
+
92
+ const app = createAppClient({ publishableKey: 'pk_...' });
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` });
96
+
97
+ // /signed-in: exchanges the link for a session and clears it from the address bar.
98
+ const user = await app.auth.completeSignIn();
99
+
100
+ // Everywhere else: the same calls as the server client, as this person.
101
+ const { rows } = await app.query('job', { orderBy: ['created_at', 'desc'] });
102
+ await app.write('forum_post', { body: 'Booked for Tuesday' }, { links: { job: rows[0].id } });
103
+ app.auth.onChange((u) => render(u));
104
+ await app.auth.signOut();
105
+ ```
106
+
107
+ The rules are kept in your own database, which applies them to every read and write, so a mistake
108
+ in the app cannot show anyone more than their role allows. A disabled person is refused on their
109
+ next call. Sessions are kept in `localStorage`; in a Capacitor app pass `storage` (Preferences) and
110
+ call `completeSignIn(url)` from the deep link that opened the app. Your own server can verify
111
+ `await app.auth.accessToken()` against `https://hub.awesomate.ai/api/sdk/v1/jwks.json`.
81
112
 
82
113
  MIT licence.
package/dist/index.d.ts CHANGED
@@ -9,12 +9,19 @@
9
9
  * const open = await db.run('open_jobs_in', { suburb: 'Carindale' }); // a saved query, by name
10
10
  * await db.call('log_visit', { customer, title: 'Possum in the roof' }); // a write recipe, one transaction
11
11
  *
12
+ * In the browser, an app's own users sign in by email link and read only what the account's
13
+ * access rules let them (Pro and above):
14
+ * const app = createAppClient({ publishableKey: 'pk_...' });
15
+ * await app.auth.signInWithLink(email, { redirectTo: `${location.origin}/signed-in` });
16
+ * await app.auth.completeSignIn(); // on /signed-in
17
+ * const { rows } = await app.query('job', {}); // their jobs only
18
+ *
12
19
  * The account's token (a hosting PAT with crm:read) is a secret: use it on a server, never in a
13
20
  * browser. End-user sign-in for browser apps comes in a later version. Reading and running saved
14
21
  * queries works on every plan; writing app data (kinds, records, saved queries, recipes) needs
15
22
  * crm:write, Support Plus and above.
16
23
  */
17
- export declare const VERSION = "0.3.0";
24
+ export declare const VERSION = "0.4.0";
18
25
  /** Augmented by the generated awesomate.d.ts, so each kind's rows are typed. */
19
26
  export interface Kinds {
20
27
  }
@@ -342,5 +349,84 @@ export interface RecipeResult {
342
349
  /** Every record the recipe wrote or archived, in step order. */
343
350
  records: string[];
344
351
  }
352
+ /** Where a session is kept: localStorage, Capacitor Preferences, or anything with these three. */
353
+ export interface SessionStorageLike {
354
+ getItem(key: string): string | null | Promise<string | null>;
355
+ setItem(key: string, value: string): void | Promise<void>;
356
+ removeItem(key: string): void | Promise<void>;
357
+ }
358
+ export interface AppUser {
359
+ id: string;
360
+ email: string;
361
+ /** owner, staff, member or one of the account's own; the database re-reads it on every call */
362
+ role: string;
363
+ contact_id: string | null;
364
+ }
365
+ export interface AppClientOptions {
366
+ /** The app's publishable key (pk_...). Not a secret: it belongs in browser code. */
367
+ publishableKey: string;
368
+ baseUrl?: string;
369
+ fetch?: typeof fetch;
370
+ /** Default: localStorage in a browser, memory elsewhere. In Capacitor, pass Preferences. */
371
+ storage?: SessionStorageLike;
372
+ /** Default: one per publishable key. */
373
+ storageKey?: string;
374
+ }
375
+ /** The sign-in token a link carries, from a URL's fragment (#awesomate_token=...), or null. */
376
+ export declare function signInTokenFrom(url: string): string | null;
377
+ export declare class AwesomateAppClient {
378
+ private readonly opts;
379
+ private readonly base;
380
+ private readonly doFetch;
381
+ private readonly storage;
382
+ private readonly key;
383
+ private session;
384
+ private refreshing;
385
+ private readonly listeners;
386
+ constructor(opts: AppClientOptions);
387
+ readonly auth: {
388
+ /** Email a sign-in link. The answer is the same whether or not the address can sign in. */
389
+ signInWithLink: (email: string, options: {
390
+ redirectTo: string;
391
+ }) => Promise<{
392
+ sent: boolean;
393
+ message: string;
394
+ }>;
395
+ /**
396
+ * Finish signing in from the link: the current page's address by default, or a URL a deep link
397
+ * opened (Capacitor). Returns the user, or null when the address carries no sign-in. The
398
+ * token is removed from the browser's address bar.
399
+ */
400
+ completeSignIn: (url?: string) => Promise<AppUser | null>;
401
+ /** The signed-in user, or null. */
402
+ user: () => Promise<AppUser | null>;
403
+ /** Called with the user on sign-in and null on sign-out (including a refresh that was refused). */
404
+ onChange: (listener: (user: AppUser | null) => void) => (() => void);
405
+ signOut: () => Promise<void>;
406
+ /** A current access token, for the app's own server to verify against the JWKS. */
407
+ accessToken: () => Promise<string | null>;
408
+ };
409
+ private request;
410
+ private read;
411
+ private load;
412
+ private save;
413
+ private clear;
414
+ /** A session whose access token has at least half a minute left, refreshing if not. */
415
+ private current;
416
+ /** One refresh at a time per client; a refresh another tab already did is picked up from storage. */
417
+ private refresh;
418
+ /** A data call as the signed-in user: refused once (a disable, a key change), it refreshes and tries again; refused twice, it signs out. */
419
+ private data;
420
+ query<K extends KindName, S extends keyof RowOf<K> = keyof RowOf<K>>(kind: K, options?: QueryOptions<RowOf<K>, S>): Promise<Omit<Page<Pick<RowOf<K>, S>>, 'hidden'>>;
421
+ queryAll<K extends KindName, S extends keyof RowOf<K> = keyof RowOf<K>>(kind: K, options?: Omit<QueryOptions<RowOf<K>, S>, 'after'> & {
422
+ maxRows?: number;
423
+ }): AsyncGenerator<Pick<RowOf<K>, S>>;
424
+ /** One record by id, or null when there is none this user may read. */
425
+ get<K extends KindName>(kind: K, id: string): Promise<RowOf<K> | null>;
426
+ /** Create a record (returns its id) or change one with options.id, inside the kind's write rule for this user. */
427
+ write<K extends KindName>(kind: K, data: Partial<RowOf<K>> | Record<string, unknown>, options?: WriteOptions): Promise<string>;
428
+ archive<K extends KindName>(kind: K, id: string): Promise<void>;
429
+ }
430
+ export declare function createAppClient(options: AppClientOptions): AwesomateAppClient;
345
431
  export declare function createClient(options: ClientOptions): AwesomateClient;
346
432
  export {};
package/dist/index.js CHANGED
@@ -9,12 +9,19 @@
9
9
  * const open = await db.run('open_jobs_in', { suburb: 'Carindale' }); // a saved query, by name
10
10
  * await db.call('log_visit', { customer, title: 'Possum in the roof' }); // a write recipe, one transaction
11
11
  *
12
+ * In the browser, an app's own users sign in by email link and read only what the account's
13
+ * access rules let them (Pro and above):
14
+ * const app = createAppClient({ publishableKey: 'pk_...' });
15
+ * await app.auth.signInWithLink(email, { redirectTo: `${location.origin}/signed-in` });
16
+ * await app.auth.completeSignIn(); // on /signed-in
17
+ * const { rows } = await app.query('job', {}); // their jobs only
18
+ *
12
19
  * The account's token (a hosting PAT with crm:read) is a secret: use it on a server, never in a
13
20
  * browser. End-user sign-in for browser apps comes in a later version. Reading and running saved
14
21
  * queries works on every plan; writing app data (kinds, records, saved queries, recipes) needs
15
22
  * crm:write, Support Plus and above.
16
23
  */
17
- export const VERSION = '0.3.0';
24
+ export const VERSION = '0.4.0';
18
25
  const DEFAULT_BASE = 'https://hub.awesomate.ai';
19
26
  export class AwesomateError extends Error {
20
27
  code;
@@ -194,6 +201,232 @@ export class AwesomateClient {
194
201
  return this.request('POST', '/api/my-crm/v1/data/query', request);
195
202
  }
196
203
  }
204
+ function memoryStorage() {
205
+ const m = new Map();
206
+ return { getItem: (k) => m.get(k) ?? null, setItem: (k, v) => { m.set(k, v); }, removeItem: (k) => { m.delete(k); } };
207
+ }
208
+ function browserStorage() {
209
+ try {
210
+ if (typeof localStorage !== 'undefined') {
211
+ localStorage.getItem('awesomate.probe');
212
+ return localStorage;
213
+ }
214
+ }
215
+ catch { /* storage blocked: a private window, a sandboxed frame */ }
216
+ return memoryStorage();
217
+ }
218
+ /** The sign-in token a link carries, from a URL's fragment (#awesomate_token=...), or null. */
219
+ export function signInTokenFrom(url) {
220
+ const hash = url.includes('#') ? url.slice(url.indexOf('#') + 1) : '';
221
+ const v = new URLSearchParams(hash).get('awesomate_token');
222
+ return v && v.length >= 20 ? v : null;
223
+ }
224
+ const REFRESH_EARLY_MS = 30_000;
225
+ export class AwesomateAppClient {
226
+ opts;
227
+ base;
228
+ doFetch;
229
+ storage;
230
+ key;
231
+ session;
232
+ refreshing = null;
233
+ listeners = new Set();
234
+ constructor(opts) {
235
+ this.opts = opts;
236
+ if (!opts?.publishableKey?.startsWith('pk_'))
237
+ throw new Error('createAppClient needs the app\'s publishable key (pk_...).');
238
+ if (/^amt_(pat|bs)_/.test(opts.publishableKey))
239
+ throw new Error('That is the account\'s own token. Never put it in browser code; use the app\'s publishable key.');
240
+ this.base = (opts.baseUrl ?? DEFAULT_BASE).replace(/\/+$/, '');
241
+ this.doFetch = opts.fetch ?? globalThis.fetch?.bind(globalThis);
242
+ if (!this.doFetch)
243
+ throw new Error('No fetch available.');
244
+ this.storage = opts.storage ?? browserStorage();
245
+ this.key = opts.storageKey ?? `awesomate.session.${opts.publishableKey.slice(-10)}`;
246
+ }
247
+ auth = {
248
+ /** Email a sign-in link. The answer is the same whether or not the address can sign in. */
249
+ signInWithLink: (email, options) => this.request('POST', '/auth/magic-link', { email, redirect_to: options.redirectTo }),
250
+ /**
251
+ * Finish signing in from the link: the current page's address by default, or a URL a deep link
252
+ * opened (Capacitor). Returns the user, or null when the address carries no sign-in. The
253
+ * token is removed from the browser's address bar.
254
+ */
255
+ completeSignIn: async (url) => {
256
+ const here = typeof location !== 'undefined' ? location.href : '';
257
+ const token = signInTokenFrom(url ?? here);
258
+ if (!token)
259
+ return null;
260
+ if (!url && typeof history !== 'undefined' && typeof location !== 'undefined') {
261
+ history.replaceState(history.state, '', location.pathname + location.search);
262
+ }
263
+ return (await this.save(await this.request('POST', '/auth/verify', { token }))).user;
264
+ },
265
+ /** The signed-in user, or null. */
266
+ user: async () => (await this.load())?.user ?? null,
267
+ /** Called with the user on sign-in and null on sign-out (including a refresh that was refused). */
268
+ onChange: (listener) => {
269
+ this.listeners.add(listener);
270
+ return () => { this.listeners.delete(listener); };
271
+ },
272
+ signOut: async () => {
273
+ const s = await this.load();
274
+ await this.clear();
275
+ if (s)
276
+ await this.request('POST', '/auth/sign-out', { refresh_token: s.refresh_token }).catch(() => undefined);
277
+ },
278
+ /** A current access token, for the app's own server to verify against the JWKS. */
279
+ accessToken: async () => (await this.current())?.access_token ?? null,
280
+ };
281
+ async request(method, path, body, token) {
282
+ const res = await this.doFetch(`${this.base}/api/sdk/v1${path}`, {
283
+ method,
284
+ headers: {
285
+ 'x-awesomate-key': this.opts.publishableKey,
286
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
287
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
288
+ },
289
+ body: body === undefined ? undefined : JSON.stringify(body),
290
+ });
291
+ if (res.status === 204)
292
+ return undefined;
293
+ const text = await res.text();
294
+ let json = {};
295
+ try {
296
+ json = text ? JSON.parse(text) : {};
297
+ }
298
+ catch { /* a proxy's error page */ }
299
+ if (res.ok)
300
+ return json;
301
+ const server = typeof json.code === 'string' ? json.code : undefined;
302
+ const message = typeof json.error === 'string' ? json.error : `The hub answered ${res.status}.`;
303
+ throw new AwesomateError(errorCode(res.status, server), message, res.status, typeof json.field === 'string' ? json.field : undefined, server);
304
+ }
305
+ async read() {
306
+ try {
307
+ const raw = await this.storage.getItem(this.key);
308
+ const s = raw ? JSON.parse(raw) : null;
309
+ return s?.access_token && s.refresh_token && s.user ? s : null;
310
+ }
311
+ catch {
312
+ return null;
313
+ }
314
+ }
315
+ async load() {
316
+ if (this.session === undefined)
317
+ this.session = await this.read();
318
+ return this.session;
319
+ }
320
+ async save(r) {
321
+ const s = { access_token: r.access_token, refresh_token: r.refresh_token, expires_at: Date.now() + r.expires_in * 1000, user: r.user };
322
+ this.session = s;
323
+ await this.storage.setItem(this.key, JSON.stringify(s));
324
+ for (const l of this.listeners)
325
+ l(s.user);
326
+ return s;
327
+ }
328
+ async clear() {
329
+ const had = this.session !== null;
330
+ this.session = null;
331
+ await this.storage.removeItem(this.key);
332
+ if (had)
333
+ for (const l of this.listeners)
334
+ l(null);
335
+ }
336
+ /** A session whose access token has at least half a minute left, refreshing if not. */
337
+ async current() {
338
+ const s = await this.load();
339
+ if (!s)
340
+ return null;
341
+ return s.expires_at - Date.now() > REFRESH_EARLY_MS ? s : this.refresh(s);
342
+ }
343
+ /** One refresh at a time per client; a refresh another tab already did is picked up from storage. */
344
+ refresh(s) {
345
+ if (this.refreshing)
346
+ return this.refreshing;
347
+ this.refreshing = (async () => {
348
+ const stored = await this.read();
349
+ if (stored && stored.refresh_token !== s.refresh_token && stored.expires_at - Date.now() > REFRESH_EARLY_MS) {
350
+ this.session = stored;
351
+ return stored;
352
+ }
353
+ try {
354
+ return await this.save(await this.request('POST', '/auth/refresh', { refresh_token: s.refresh_token }));
355
+ }
356
+ catch (err) {
357
+ if (!(err instanceof AwesomateError) || err.code !== 'unauthenticated')
358
+ throw err;
359
+ const after = await this.read();
360
+ if (after && after.refresh_token !== s.refresh_token) {
361
+ this.session = after;
362
+ return after;
363
+ }
364
+ await this.clear();
365
+ return null;
366
+ }
367
+ })().finally(() => { this.refreshing = null; });
368
+ return this.refreshing;
369
+ }
370
+ /** A data call as the signed-in user: refused once (a disable, a key change), it refreshes and tries again; refused twice, it signs out. */
371
+ async data(method, path, body) {
372
+ let s = await this.current();
373
+ if (!s)
374
+ throw new AwesomateError('unauthenticated', 'Sign in first.', 401);
375
+ try {
376
+ return await this.request(method, path, body, s.access_token);
377
+ }
378
+ catch (err) {
379
+ if (!(err instanceof AwesomateError) || err.code !== 'unauthenticated')
380
+ throw err;
381
+ s = await this.refresh(s);
382
+ if (!s)
383
+ throw err;
384
+ return this.request(method, path, body, s.access_token);
385
+ }
386
+ }
387
+ async query(kind, options = {}) {
388
+ const r = await this.data('POST', '/rows/query', { kind, ...options });
389
+ return { rows: r.rows, next: r.next, count: r.count, applied: r.applied, asAt: r.as_at };
390
+ }
391
+ async *queryAll(kind, options = {}) {
392
+ const { maxRows = 10_000, ...rest } = options;
393
+ let after;
394
+ let seen = 0;
395
+ do {
396
+ const page = await this.query(kind, { limit: 1000, ...rest, ...(after ? { after } : {}) });
397
+ for (const row of page.rows) {
398
+ if (seen++ >= maxRows)
399
+ return;
400
+ yield row;
401
+ }
402
+ after = page.next ?? undefined;
403
+ } while (after);
404
+ }
405
+ /** One record by id, or null when there is none this user may read. */
406
+ async get(kind, id) {
407
+ try {
408
+ return (await this.data('GET', `/rows/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`)).row;
409
+ }
410
+ catch (err) {
411
+ if (err instanceof AwesomateError && err.code === 'not_found')
412
+ return null;
413
+ throw err;
414
+ }
415
+ }
416
+ /** Create a record (returns its id) or change one with options.id, inside the kind's write rule for this user. */
417
+ async write(kind, data, options = {}) {
418
+ const r = await this.data('POST', '/call', {
419
+ fn: 'write_record', args: { kind, data, links: options.links ?? {}, ...(options.id ? { id: options.id } : {}) },
420
+ });
421
+ return r.result.id;
422
+ }
423
+ async archive(kind, id) {
424
+ await this.data('POST', '/call', { fn: 'archive_record', args: { kind, id } });
425
+ }
426
+ }
427
+ export function createAppClient(options) {
428
+ return new AwesomateAppClient(options);
429
+ }
197
430
  export function createClient(options) {
198
431
  return new AwesomateClient(options);
199
432
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@awesomate/sdk",
3
- "version": "0.3.0",
4
- "description": "Read your own Awesomate data from Node: query and page through your contact list with types generated from your fields",
3
+ "version": "0.4.0",
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",
7
7
  "publishConfig": {