@awesomate/sdk 0.2.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
@@ -6,16 +6,44 @@
6
6
  * const db = createClient({ token: process.env.AWESOMATE_TOKEN! });
7
7
  * const { rows, next } = await db.query('contact', { where: { created_at: { gte: '$YEAR_BEGIN' } }, limit: 50 });
8
8
  *
9
+ * const open = await db.run('open_jobs_in', { suburb: 'Carindale' }); // a saved query, by name
10
+ * await db.call('log_visit', { customer, title: 'Possum in the roof' }); // a write recipe, one transaction
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
+ *
9
19
  * The account's token (a hosting PAT with crm:read) is a secret: use it on a server, never in a
10
- * browser. End-user sign-in for browser apps comes in a later version. Writing app data (kinds and
11
- * their records) needs crm:write, Support Plus and above.
20
+ * browser. End-user sign-in for browser apps comes in a later version. Reading and running saved
21
+ * queries works on every plan; writing app data (kinds, records, saved queries, recipes) needs
22
+ * crm:write, Support Plus and above.
12
23
  */
13
- export declare const VERSION = "0.2.0";
24
+ export declare const VERSION = "0.4.0";
14
25
  /** Augmented by the generated awesomate.d.ts, so each kind's rows are typed. */
15
26
  export interface Kinds {
16
27
  }
17
28
  export type KindName = [keyof Kinds] extends [never] ? string : Extract<keyof Kinds, string>;
18
29
  export type RowOf<K> = K extends keyof Kinds ? Kinds[K] : Record<string, unknown>;
30
+ /** Augmented by the generated awesomate.d.ts: each saved query's params and row. */
31
+ export interface Queries {
32
+ }
33
+ /** Augmented by the generated awesomate.d.ts: each write recipe's args. */
34
+ export interface Recipes {
35
+ }
36
+ export type QueryName = [keyof Queries] extends [never] ? string : Extract<keyof Queries, string>;
37
+ export type ParamsOf<Q> = Q extends keyof Queries ? (Queries[Q] extends {
38
+ params: infer P;
39
+ } ? P : never) : Record<string, unknown>;
40
+ export type QueryRowOf<Q> = Q extends keyof Queries ? (Queries[Q] extends {
41
+ row: infer R;
42
+ } ? R : never) : Record<string, unknown>;
43
+ export type RecipeName = [keyof Recipes] extends [never] ? string : Extract<keyof Recipes, string>;
44
+ export type ArgsOf<R> = R extends keyof Recipes ? (Recipes[R] extends {
45
+ args: infer A;
46
+ } ? A : never) : Record<string, unknown>;
19
47
  type TextOps<V extends string> = {
20
48
  eq?: V | null;
21
49
  neq?: V | null;
@@ -123,7 +151,7 @@ export declare class AwesomateClient {
123
151
  private readonly base;
124
152
  private readonly doFetch;
125
153
  constructor(opts: ClientOptions);
126
- private call;
154
+ private request;
127
155
  /** The kinds this token can read, their columns, operators and examples. */
128
156
  schema(): Promise<{
129
157
  kinds: Array<{
@@ -160,6 +188,34 @@ export declare class AwesomateClient {
160
188
  write<K extends KindName>(kind: K, data: Partial<RowOf<K>> | Record<string, unknown>, options?: WriteOptions): Promise<string>;
161
189
  /** Archive a record: it leaves every read and is kept for a restore. */
162
190
  archive<K extends KindName>(kind: K, id: string): Promise<void>;
191
+ /** The saved queries on this account. */
192
+ queries(): Promise<SavedQueryDescription[]>;
193
+ /**
194
+ * Save a query by name (saving a name again replaces it). The spec is the query() grammar on one
195
+ * kind, with {"$param": "<name>"} where a caller's value goes; it is compiled before it is
196
+ * stored, so a column it cannot read is refused now rather than when it runs.
197
+ */
198
+ saveQuery(spec: SavedQuerySpec): Promise<SavedQueryDescription>;
199
+ archiveQuery(key: string): Promise<void>;
200
+ /** Run a saved query with its params. An optional param left out drops its condition. */
201
+ run<Q extends QueryName>(query: Q, params?: ParamsOf<Q>, options?: {
202
+ limit?: number;
203
+ after?: string;
204
+ tz?: string;
205
+ }): Promise<Page<QueryRowOf<Q>>>;
206
+ /** The write recipes on this account. */
207
+ recipes(): Promise<RecipeDescription[]>;
208
+ /**
209
+ * Save a write recipe: up to 10 write_record / archive_record steps run in one transaction.
210
+ * {"$param": "<name>"} takes a caller's value, {"$step": "<as>"} the id an earlier step wrote.
211
+ */
212
+ saveRecipe(spec: RecipeSpec): Promise<RecipeDescription>;
213
+ archiveRecipe(key: string): Promise<void>;
214
+ /**
215
+ * Run a write recipe. Every step commits or none does; a refusal names the step and the field.
216
+ * Never retried: a write that may have landed is not safe to send twice.
217
+ */
218
+ call<R extends RecipeName>(recipe: R, args?: ArgsOf<R>): Promise<RecipeResult>;
163
219
  /** Grouped numbers (counts, sums) in the Business Data API's shape. */
164
220
  aggregate(request: {
165
221
  measures: Array<{
@@ -227,5 +283,150 @@ export interface WriteOptions {
227
283
  /** Set a link by its key ('<id>'), or end it (null). */
228
284
  links?: Record<string, string | null>;
229
285
  }
286
+ export type ParamType = 'text' | 'number' | 'date' | 'datetime' | 'boolean' | 'uuid' | 'text_list' | 'number_list';
287
+ export interface ParamSpec {
288
+ name: string;
289
+ /** Default text. */
290
+ type?: ParamType;
291
+ required?: boolean;
292
+ default?: unknown;
293
+ label?: string;
294
+ }
295
+ /** Where a caller's value goes in a saved query or a recipe. */
296
+ export type Param = {
297
+ $param: string;
298
+ };
299
+ export interface SavedQuerySpec {
300
+ key: string;
301
+ label: string;
302
+ description?: string;
303
+ /** 'contact' or an app kind. */
304
+ kind: string;
305
+ spec: {
306
+ where?: Record<string, unknown>;
307
+ orderBy?: Array<[string, 'asc' | 'desc']>;
308
+ select?: string[];
309
+ limit?: number;
310
+ };
311
+ params?: ParamSpec[];
312
+ }
313
+ export interface SavedQueryDescription extends Required<Omit<SavedQuerySpec, 'description' | 'params'>> {
314
+ description: string | null;
315
+ params: ParamSpec[];
316
+ updated_at: string;
317
+ }
318
+ export type RecipeStep = {
319
+ op: 'write_record';
320
+ kind: string;
321
+ id?: Param | {
322
+ $step: string;
323
+ };
324
+ as?: string;
325
+ data?: Record<string, unknown>;
326
+ links?: Record<string, unknown>;
327
+ } | {
328
+ op: 'archive_record';
329
+ kind: string;
330
+ id: Param | {
331
+ $step: string;
332
+ };
333
+ };
334
+ export interface RecipeSpec {
335
+ key: string;
336
+ label: string;
337
+ description?: string;
338
+ params?: ParamSpec[];
339
+ steps: RecipeStep[];
340
+ }
341
+ export interface RecipeDescription extends Required<Omit<RecipeSpec, 'description' | 'params'>> {
342
+ description: string | null;
343
+ params: ParamSpec[];
344
+ updated_at: string;
345
+ }
346
+ export interface RecipeResult {
347
+ /** The id each step named with `as` wrote. */
348
+ ids: Record<string, string>;
349
+ /** Every record the recipe wrote or archived, in step order. */
350
+ records: string[];
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;
230
431
  export declare function createClient(options: ClientOptions): AwesomateClient;
231
432
  export {};
package/dist/index.js CHANGED
@@ -6,11 +6,22 @@
6
6
  * const db = createClient({ token: process.env.AWESOMATE_TOKEN! });
7
7
  * const { rows, next } = await db.query('contact', { where: { created_at: { gte: '$YEAR_BEGIN' } }, limit: 50 });
8
8
  *
9
+ * const open = await db.run('open_jobs_in', { suburb: 'Carindale' }); // a saved query, by name
10
+ * await db.call('log_visit', { customer, title: 'Possum in the roof' }); // a write recipe, one transaction
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
+ *
9
19
  * The account's token (a hosting PAT with crm:read) is a secret: use it on a server, never in a
10
- * browser. End-user sign-in for browser apps comes in a later version. Writing app data (kinds and
11
- * their records) needs crm:write, Support Plus and above.
20
+ * browser. End-user sign-in for browser apps comes in a later version. Reading and running saved
21
+ * queries works on every plan; writing app data (kinds, records, saved queries, recipes) needs
22
+ * crm:write, Support Plus and above.
12
23
  */
13
- export const VERSION = '0.2.0';
24
+ export const VERSION = '0.4.0';
14
25
  const DEFAULT_BASE = 'https://hub.awesomate.ai';
15
26
  export class AwesomateError extends Error {
16
27
  code;
@@ -58,7 +69,7 @@ export class AwesomateClient {
58
69
  if (!this.doFetch)
59
70
  throw new Error('No fetch available: use Node 18 or later, or pass fetch.');
60
71
  }
61
- async call(method, path, body, retry = true) {
72
+ async request(method, path, body, retry = true) {
62
73
  const res = await this.doFetch(`${this.base}${path}`, {
63
74
  method,
64
75
  headers: {
@@ -81,20 +92,20 @@ export class AwesomateClient {
81
92
  // The field list changed while the read ran (the view is rebuilt on every field edit): a read
82
93
  // is safe to run again, once.
83
94
  if (code === 'conflict' && retry)
84
- return this.call(method, path, body, false);
95
+ return this.request(method, path, body, false);
85
96
  const message = typeof json.error === 'string' ? json.error : `The hub answered ${res.status}.`;
86
97
  throw new AwesomateError(code, message, res.status, typeof json.field === 'string' ? json.field : undefined, server);
87
98
  }
88
99
  /** The kinds this token can read, their columns, operators and examples. */
89
100
  schema() {
90
- return this.call('GET', '/api/my-crm/v1/rows/schema');
101
+ return this.request('GET', '/api/my-crm/v1/rows/schema');
91
102
  }
92
103
  /** The readable kinds as a TypeScript file (what the types command writes). */
93
104
  types() {
94
- return this.call('GET', '/api/my-crm/v1/rows/types');
105
+ return this.request('GET', '/api/my-crm/v1/rows/types');
95
106
  }
96
107
  async query(kind, options = {}) {
97
- const r = await this.call('POST', '/api/my-crm/v1/rows/query', { kind, ...options });
108
+ const r = await this.request('POST', '/api/my-crm/v1/rows/query', { kind, ...options });
98
109
  return { rows: r.rows, next: r.next, count: r.count, applied: r.applied, hidden: r.hidden, asAt: r.as_at };
99
110
  }
100
111
  /** Every matching row, a page at a time. maxRows guards against reading a whole list by accident. */
@@ -116,7 +127,7 @@ export class AwesomateClient {
116
127
  async get(kind, id, options = {}) {
117
128
  try {
118
129
  const qs = options.tz ? `?tz=${encodeURIComponent(options.tz)}` : '';
119
- const r = await this.call('GET', `/api/my-crm/v1/rows/${encodeURIComponent(kind)}/${encodeURIComponent(id)}${qs}`);
130
+ const r = await this.request('GET', `/api/my-crm/v1/rows/${encodeURIComponent(kind)}/${encodeURIComponent(id)}${qs}`);
120
131
  return r.row;
121
132
  }
122
133
  catch (err) {
@@ -127,27 +138,294 @@ export class AwesomateClient {
127
138
  }
128
139
  /** The app kinds this account has defined. */
129
140
  async kinds() {
130
- return (await this.call('GET', '/api/my-crm/v1/kinds')).kinds;
141
+ return (await this.request('GET', '/api/my-crm/v1/kinds')).kinds;
131
142
  }
132
143
  /** Define an app kind (a table an app keeps). No migration: registry rows and generated views. */
133
144
  async defineKind(spec) {
134
- return (await this.call('POST', '/api/my-crm/v1/kinds', spec)).kind;
145
+ return (await this.request('POST', '/api/my-crm/v1/kinds', spec)).kind;
135
146
  }
136
147
  /** Create a record (returns its id), or change one with options.id. Every value is checked against the kind. */
137
148
  async write(kind, data, options = {}) {
138
- const r = await this.call('POST', '/api/my-crm/v1/call', {
149
+ const r = await this.request('POST', '/api/my-crm/v1/call', {
139
150
  fn: 'write_record', args: { kind, data, links: options.links ?? {}, ...(options.id ? { id: options.id } : {}) },
140
151
  }, false);
141
152
  return r.result.id;
142
153
  }
143
154
  /** Archive a record: it leaves every read and is kept for a restore. */
144
155
  async archive(kind, id) {
145
- await this.call('POST', '/api/my-crm/v1/call', { fn: 'archive_record', args: { kind, id } }, false);
156
+ await this.request('POST', '/api/my-crm/v1/call', { fn: 'archive_record', args: { kind, id } }, false);
157
+ }
158
+ /** The saved queries on this account. */
159
+ async queries() {
160
+ return (await this.request('GET', '/api/my-crm/v1/queries')).queries;
161
+ }
162
+ /**
163
+ * Save a query by name (saving a name again replaces it). The spec is the query() grammar on one
164
+ * kind, with {"$param": "<name>"} where a caller's value goes; it is compiled before it is
165
+ * stored, so a column it cannot read is refused now rather than when it runs.
166
+ */
167
+ async saveQuery(spec) {
168
+ return (await this.request('POST', '/api/my-crm/v1/queries', spec, false)).query;
169
+ }
170
+ async archiveQuery(key) {
171
+ await this.request('DELETE', `/api/my-crm/v1/queries/${encodeURIComponent(key)}`, undefined, false);
172
+ }
173
+ /** Run a saved query with its params. An optional param left out drops its condition. */
174
+ async run(query, params = {}, options = {}) {
175
+ const r = await this.request('POST', `/api/my-crm/v1/queries/${encodeURIComponent(query)}/run`, { params, ...options });
176
+ return { rows: r.rows, next: r.next, count: r.count, applied: r.applied, hidden: r.hidden, asAt: r.as_at };
177
+ }
178
+ /** The write recipes on this account. */
179
+ async recipes() {
180
+ return (await this.request('GET', '/api/my-crm/v1/recipes')).recipes;
181
+ }
182
+ /**
183
+ * Save a write recipe: up to 10 write_record / archive_record steps run in one transaction.
184
+ * {"$param": "<name>"} takes a caller's value, {"$step": "<as>"} the id an earlier step wrote.
185
+ */
186
+ async saveRecipe(spec) {
187
+ return (await this.request('POST', '/api/my-crm/v1/recipes', spec, false)).recipe;
188
+ }
189
+ async archiveRecipe(key) {
190
+ await this.request('DELETE', `/api/my-crm/v1/recipes/${encodeURIComponent(key)}`, undefined, false);
191
+ }
192
+ /**
193
+ * Run a write recipe. Every step commits or none does; a refusal names the step and the field.
194
+ * Never retried: a write that may have landed is not safe to send twice.
195
+ */
196
+ async call(recipe, args = {}) {
197
+ return (await this.request('POST', '/api/my-crm/v1/call', { fn: recipe, args }, false)).result;
146
198
  }
147
199
  /** Grouped numbers (counts, sums) in the Business Data API's shape. */
148
200
  aggregate(request) {
149
- return this.call('POST', '/api/my-crm/v1/data/query', request);
201
+ return this.request('POST', '/api/my-crm/v1/data/query', request);
202
+ }
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
+ }
150
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);
151
429
  }
152
430
  export function createClient(options) {
153
431
  return new AwesomateClient(options);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@awesomate/sdk",
3
- "version": "0.2.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": {