@synthigy/sdk 0.2.0 → 0.2.2

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
@@ -60,7 +60,9 @@ Run it with `synthigy exec -- node hello.ts`.
60
60
  `watchSqlTemplate` for live analytics, `watch` for raw record events.
61
61
 
62
62
  - **`actingAs` per call.** Every read and write can run as a specific user, with
63
- IAM / RBAC / row-level security enforced *server-side*. Tokens never have to
63
+ IAM / RBAC / row-level security enforced *server-side*; `whoami({ actingAs })`
64
+ shows who a call runs as — roles, superuser, whether this client may act as
65
+ users. Tokens never have to
64
66
  reach the browser. `client.login` runs the OIDC authorization-code + PKCE
65
67
  exchange for your BFF and leaves sessions to you; `createBrowserLogin` does
66
68
  the same for a browser app, as a public client with silent renew and tokens
@@ -2,7 +2,6 @@
2
2
  // Unified codegen tool:
3
3
  // node codegen.mjs <xsql...> [outDir] — describe + gen TS (saves ops.ir.json)
4
4
  // node codegen.mjs check <xsql...> — re-describe, diff, exit 1 on drift
5
- // node codegen.mjs pull [endpoint] [out] — pull schema.json (wraps pull.mjs)
6
5
  //
7
6
  // Config via env: SYNTHIGY_ENDPOINT, SYNTHIGY_CLIENT_ID, SYNTHIGY_CLIENT_SECRET
8
7
 
@@ -11,7 +10,7 @@ import path from 'node:path'
11
10
  import { createHash } from 'node:crypto'
12
11
  import { execFileSync } from 'node:child_process'
13
12
  import { createRequire } from 'node:module'
14
- import { TS, tsScalar, pascal, camel } from './lib.mjs'
13
+ import { TS, tsScalar, pascal, camel, warnStaleSchema } from './lib.mjs'
15
14
  import { PLATFORM_AUDIENCE } from '../src/index.js'
16
15
 
17
16
  let tsVersion = null
@@ -29,10 +28,9 @@ synthigy-codegen — generate TypeScript types from XSQL operations
29
28
  Usage:
30
29
  codegen.mjs [flags] <a.xsql> [b.xsql ...] [outDir] generate TS from one or more xsql files
31
30
  codegen.mjs check [flags] <a.xsql> [b.xsql ...] re-describe, diff IR, exit 1 on drift
32
- codegen.mjs pull [endpoint] [out] pull schema.json only
33
31
 
34
32
  Flags:
35
- --pull force schema refresh before generating
33
+ --pull refresh schema.json beside the .xsql before generating
36
34
  --keys snake|camel field key casing in generated types (default: snake)
37
35
  --no-writes omit sync / stack / delete methods
38
36
  --schema <path> explicit schema.json path (skips auto-pull)
@@ -47,7 +45,6 @@ Examples:
47
45
  node codegen.mjs movies.xsql src/generated/ first run auto-pulls schema
48
46
  node codegen.mjs --pull movies.xsql src/generated/ force schema refresh + generate
49
47
  node codegen.mjs check movies.xsql CI drift check
50
- node codegen.mjs pull http://localhost:7887 pull schema only
51
48
  `.trim())
52
49
  process.exit(0)
53
50
  }
@@ -71,13 +68,6 @@ if (!VALID_KEYS.has(flags.keys)) {
71
68
  }
72
69
 
73
70
  const checkMode = pos[0] === 'check'
74
- const pullMode = pos[0] === 'pull'
75
-
76
- if (pullMode) {
77
- execFileSync(process.execPath, [path.join(here, 'pull.mjs'), ...pos.slice(1)],
78
- { stdio: 'inherit', env: process.env })
79
- process.exit(0)
80
- }
81
71
 
82
72
  // Positionals: last non-.xsql arg is outDir; everything before it (files or dirs)
83
73
  // is input. check mode has no outDir — every positional is input.
@@ -90,7 +80,6 @@ const xsqlArgs = hasExplicitOut ? rawPos.slice(0, -1) : rawPos
90
80
  if (!xsqlArgs.length) {
91
81
  console.error('usage: node codegen.mjs [flags] <file.xsql|dir> [...] [outDir] (dirs searched recursively)')
92
82
  console.error(' node codegen.mjs check <file.xsql|dir> [...]')
93
- console.error(' node codegen.mjs pull [endpoint] [schema.json]')
94
83
  console.error('flags: --pull force schema refresh')
95
84
  console.error(' --keys snake|camel field casing (default: snake)')
96
85
  console.error(' --no-writes omit sync/stack/delete methods')
@@ -99,6 +88,8 @@ if (!xsqlArgs.length) {
99
88
  }
100
89
 
101
90
  const isDir = p => fs.existsSync(p) && fs.statSync(p).isDirectory()
91
+ const missing = xsqlArgs.filter(a => !fs.existsSync(path.resolve(a)))
92
+ if (missing.length) { console.error(`no such .xsql file or directory: ${missing.join(', ')}`); process.exit(1) }
102
93
  const xsqlPaths = xsqlArgs.flatMap(a => {
103
94
  const p = path.resolve(a)
104
95
  if (isDir(p))
@@ -140,6 +131,8 @@ if (!flags.schemaOverride && (flags.pull || !fs.existsSync(schemaPath))) {
140
131
  execFileSync(process.execPath,
141
132
  [path.join(here, 'pull.mjs'), endpoint(), schemaPath],
142
133
  { stdio: 'inherit', env: process.env })
134
+ } else {
135
+ await warnStaleSchema(ENDPOINT, schemaPath)
143
136
  }
144
137
 
145
138
  if (!fs.existsSync(schemaPath)) {
package/codegen/lib.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import fs from 'node:fs'
2
+
1
3
  // Shared codegen primitives for the TS emitters — one home for the scalar-type
2
4
  // map + casing so the read and write emitters can't drift apart.
3
5
 
@@ -28,3 +30,25 @@ export const camel = (s) => { const p = pascal(s); return p[0].toLowerCase() + p
28
30
  export const snake = (s) => words(s).join('_')
29
31
  // kebab wire keys aren't valid TS identifiers → keep verbatim, quote when needed.
30
32
  export const tsKey = (k) => (/^[A-Za-z_$][\w$]*$/.test(k) ? k : JSON.stringify(k))
33
+
34
+ // One line on `write` when the schema's deploy stamp is older than the model at
35
+ // /.well-known/synthigy. Silent on any failure: generation stays offline-capable.
36
+ export async function warnStaleSchema(endpoint, schemaPath, write = (s) => console.error(s)) {
37
+ if (!endpoint || !fs.existsSync(schemaPath)) return
38
+ let local, remote
39
+ try {
40
+ local = JSON.parse(fs.readFileSync(schemaPath, 'utf8')).datasets ?? {}
41
+ const r = await fetch(`${endpoint.replace(/\/$/, '')}/.well-known/synthigy`,
42
+ { signal: AbortSignal.timeout(2000) })
43
+ if (!r.ok) return
44
+ remote = (await r.json()).datasets ?? {}
45
+ } catch {
46
+ return
47
+ }
48
+ const instant = (s) => (Number.isNaN(Date.parse(s)) ? s : Date.parse(s))
49
+ const changed = Object.keys(remote).filter((id) => instant(local[id]?.['deployed-at']) !== instant(remote[id]))
50
+ .concat(Object.keys(local).filter((id) => !(id in remote)))
51
+ if (changed.length) {
52
+ write(`warning: ${schemaPath} is stale against ${endpoint} (${changed.length} datasets changed since the pull) — re-run with --pull`)
53
+ }
54
+ }
package/codegen/pull.mjs CHANGED
@@ -21,8 +21,9 @@ import path from 'node:path'
21
21
  import { createClient } from '../src/index.js'
22
22
 
23
23
  const endpoint = process.argv[2] ?? process.env.SYNTHIGY_ENDPOINT
24
- if (!endpoint) {
25
- console.error('usage: node pull.mjs <endpoint> (or set SYNTHIGY_ENDPOINT)')
24
+ const out = process.argv[3]
25
+ if (!endpoint || !out) {
26
+ console.error('usage: node pull.mjs <endpoint> <schema.json>')
26
27
  process.exit(1)
27
28
  }
28
29
 
@@ -41,8 +42,7 @@ if (SYNTHIGY_CLIENT_ID && SYNTHIGY_CLIENT_SECRET) {
41
42
  config.token = SYNTHIGY_TOKEN ?? '' // '' = authless (bare server)
42
43
  }
43
44
 
44
- const here = path.dirname(new URL(import.meta.url).pathname)
45
- const outPath = process.argv[3] ? path.resolve(process.argv[3]) : path.join(here, 'schema.json')
45
+ const outPath = path.resolve(out)
46
46
 
47
47
  const client = createClient(config)
48
48
  const schema = await client.schema()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synthigy/sdk",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Zero-dependency client for Synthigy's /data endpoint",
5
5
  "author": "Robert Geršak",
6
6
  "license": "MIT",
@@ -14,7 +14,8 @@
14
14
  import { pkce, decodeJwtPayload } from './login.js'
15
15
 
16
16
  const PENDING_PREFIX = 'synthigy.login:'
17
- const PENDING_TTL_MS = 5 * 60_000
17
+ // must exceed the server's 8-minute login window, or slow logins fail LOGIN_STATE_UNKNOWN
18
+ const PENDING_TTL_MS = 10 * 60_000
18
19
  const MESSAGE_SOURCE = 'synthigy-login'
19
20
 
20
21
  function randomToken(bytes) {
package/src/index.d.ts CHANGED
@@ -275,6 +275,13 @@ export interface Schema {
275
275
  * UNAUTHORIZED, CLIENT_NOT_FOUND, CLIENT_INACTIVE,
276
276
  * PUBLIC_CLIENT_FORBIDDEN, NOT_TRUSTED, USER_NOT_FOUND, FORBIDDEN
277
277
  *
278
+ * Token mint refused (client credentials):
279
+ * INVALID_CLIENT, INVALID_SCOPE, INVALID_AUDIENCE
280
+ *
281
+ * Permission denied (RBAC/RLS):
282
+ * ENTITY_FORBIDDEN, RELATION_FORBIDDEN, ATTRIBUTE_FORBIDDEN,
283
+ * ROW_FORBIDDEN, CREATE_FORBIDDEN, DELETE_FORBIDDEN, SLOT_OCCUPIED
284
+ *
278
285
  * Request shape:
279
286
  * INVALID_BODY, NO_OPERATIONS, BAD_OP, MISSING_ENTITY,
280
287
  * BAD_ARGS_SHAPE
@@ -302,6 +309,9 @@ export type SynthigyErrorCode =
302
309
  | 'UNAUTHORIZED' | 'CLIENT_NOT_FOUND' | 'CLIENT_INACTIVE'
303
310
  | 'PUBLIC_CLIENT_FORBIDDEN' | 'NOT_TRUSTED' | 'USER_NOT_FOUND'
304
311
  | 'FORBIDDEN' | 'NO_TOKEN'
312
+ | 'INVALID_CLIENT' | 'INVALID_SCOPE' | 'INVALID_AUDIENCE'
313
+ | 'ENTITY_FORBIDDEN' | 'RELATION_FORBIDDEN' | 'ATTRIBUTE_FORBIDDEN'
314
+ | 'ROW_FORBIDDEN' | 'CREATE_FORBIDDEN' | 'DELETE_FORBIDDEN' | 'SLOT_OCCUPIED'
305
315
  | 'NO_LOGIN_STORE' | 'LOGIN_REQUIRES_CONFIDENTIAL_CLIENT'
306
316
  | 'LOGIN_STATE_UNKNOWN' | 'LOGIN_NONCE_MISMATCH' | 'LOGIN_EXCHANGE_FAILED'
307
317
  | 'INVALID_BODY' | 'NO_OPERATIONS' | 'BAD_OP' | 'MISSING_ENTITY'
@@ -314,11 +324,50 @@ export type SynthigyErrorCode =
314
324
  | 'HTTP_ERROR' | 'OPERATION_ERROR' | 'INTERNAL_ERROR'
315
325
  | (string & {})
316
326
 
327
+ /** `whoami()` — the identity a call runs as. `client` is null in-process (no OAuth client). */
328
+ export interface WhoAmI {
329
+ xid: string | null
330
+ name: string | null
331
+ active: boolean
332
+ superuser: boolean
333
+ roles: string[]
334
+ groups: string[]
335
+ /** `trusted`: this client may act as users (confidential, active, flagged trusted). */
336
+ client: { id: string; trusted: boolean } | null
337
+ }
338
+
339
+ export type SynthigyErrorCategory =
340
+ | 'auth' | 'iam' | 'validation' | 'not_found'
341
+ | 'conflict' | 'rate_limit' | 'network' | 'internal'
342
+
343
+ /** 1-based source position (XSQL_PARSE_ERROR, TEMPLATE_*). */
344
+ export interface SourcePosition { line: number; col: number }
345
+
317
346
  export class SynthigyError extends Error {
318
347
  name: 'SynthigyError'
319
348
  code: SynthigyErrorCode
349
+ category: SynthigyErrorCategory
350
+ /** True for known network/rate_limit/internal codes, or when the server marked the error retryable. */
351
+ retryable: boolean
320
352
  details?: unknown
321
- constructor(message: string, code: string, details?: unknown)
353
+ hint?: string
354
+ available?: string[]
355
+ path?: unknown
356
+ entity?: string
357
+ relation?: string
358
+ operator?: string
359
+ requestId?: string
360
+ status?: number
361
+ line?: number
362
+ col?: number
363
+ start?: SourcePosition
364
+ end?: SourcePosition
365
+ diagnostics?: unknown[]
366
+ constructor(message: string, code: string, details?: unknown, extras?: {
367
+ hint?: string; available?: string[]; path?: unknown; entity?: string; relation?: string
368
+ operator?: string; requestId?: string; status?: number; retryable?: boolean
369
+ line?: number; col?: number; start?: SourcePosition; end?: SourcePosition; diagnostics?: unknown[]
370
+ })
322
371
  }
323
372
 
324
373
  // ============================================================================
@@ -779,6 +828,9 @@ export class SynthigyClient {
779
828
  */
780
829
  deployedModel(opts?: ExecOpts): Promise<Record<string, unknown>>
781
830
 
831
+ /** Who a call runs as, after `actingAs` — roles, superuser, and whether this client may act as users. */
832
+ whoami(opts?: ExecOpts): Promise<WhoAmI>
833
+
782
834
  /**
783
835
  * Deploy a dataset version from a modeler export — pass the export file's
784
836
  * contents verbatim, the server decodes it. Requires `dataset:deploy`.
@@ -1200,6 +1252,7 @@ export const op: {
1200
1252
  query(xsql: string, params?: Record<string, unknown>, verb?: string): Operation
1201
1253
  deployedModel(): Operation
1202
1254
  runtimeModel(): Operation
1255
+ whoami(): Operation
1203
1256
  deploy(exportContents: string): Operation
1204
1257
  destroy(datasetXid: string): Operation
1205
1258
  }
@@ -1281,6 +1334,7 @@ export const onboardComplete: SynthigyClient['onboardComplete']
1281
1334
  export const deploy: SynthigyClient['deploy']
1282
1335
  export const destroy: SynthigyClient['destroy']
1283
1336
  export const deployedModel: SynthigyClient['deployedModel']
1337
+ export const whoami: SynthigyClient['whoami']
1284
1338
  export const runtimeModel: SynthigyClient['runtimeModel']
1285
1339
  export const token: SynthigyClient['token']
1286
1340
  export function history(): SynthigyClient['history']
package/src/index.js CHANGED
@@ -415,10 +415,7 @@ class TokenManager {
415
415
  body: params,
416
416
  })
417
417
 
418
- if (!resp.ok) {
419
- const text = await resp.text()
420
- throw new Error(`Token request failed (${resp.status}): ${text}`)
421
- }
418
+ if (!resp.ok) throw _tokenRefusal(resp.status, await resp.text())
422
419
 
423
420
  const data = await resp.json()
424
421
  this._tokens.set(key, {
@@ -434,6 +431,28 @@ class TokenManager {
434
431
  }
435
432
  }
436
433
 
434
+ /** RFC 6749 §5.2 / RFC 8707 refusals with their own code; any other refusal is UNAUTHORIZED. */
435
+ const REFUSAL_CODES = {
436
+ invalid_client: 'INVALID_CLIENT',
437
+ invalid_scope: 'INVALID_SCOPE',
438
+ invalid_target: 'INVALID_AUDIENCE',
439
+ }
440
+
441
+ function _tokenRefusal(status, text) {
442
+ let oauth = null
443
+ try { oauth = JSON.parse(text) } catch {}
444
+ if (typeof oauth?.error !== 'string' || oauth.error === '') {
445
+ return new SynthigyError(`Token request failed (${status}): ${text}`, 'UNAUTHORIZED', undefined, { status })
446
+ }
447
+ const code = Object.hasOwn(REFUSAL_CODES, oauth.error) ? REFUSAL_CODES[oauth.error] : 'UNAUTHORIZED'
448
+ const desc = typeof oauth.error_description === 'string' && oauth.error_description !== ''
449
+ ? ` — ${oauth.error_description}` : ''
450
+ return new SynthigyError(`Token request refused (${status}): ${oauth.error}${desc}`, code, oauth, {
451
+ status,
452
+ hint: code === 'INVALID_CLIENT' ? 'check the client id and secret, and that the client is active' : undefined,
453
+ })
454
+ }
455
+
437
456
  // ============================================================================
438
457
  // Supervised stdio — SYNTHIGY_SUPERVISED=1. The SDK never mints locally
439
458
  // under this mode: the CLI/commander is the platform's stdio owner, so a
@@ -665,6 +684,10 @@ const ERROR_CATEGORIES = {
665
684
  LOGIN_NONCE_MISMATCH: 'auth',
666
685
  LOGIN_EXCHANGE_FAILED: 'auth',
667
686
  SILENT_TIMEOUT: 'auth',
687
+ USERINFO_SUB_MISMATCH: 'auth',
688
+ INVALID_CLIENT: 'auth',
689
+ INVALID_SCOPE: 'auth',
690
+ INVALID_AUDIENCE: 'auth',
668
691
  // OAuth error codes the IdP redirects back with (RFC 6749 4.1.2.1, OIDC 3.1.2.6)
669
692
  access_denied: 'auth',
670
693
  login_required: 'auth',
@@ -676,6 +699,12 @@ const ERROR_CATEGORIES = {
676
699
  ENTITY_FORBIDDEN: 'iam',
677
700
  ENTITY_NOT_READABLE: 'iam',
678
701
  RELATION_NOT_READABLE: 'iam',
702
+ RELATION_FORBIDDEN: 'iam',
703
+ ATTRIBUTE_FORBIDDEN: 'iam',
704
+ ROW_FORBIDDEN: 'iam',
705
+ CREATE_FORBIDDEN: 'iam',
706
+ DELETE_FORBIDDEN: 'iam',
707
+ SLOT_OCCUPIED: 'iam',
679
708
  // validation
680
709
  INVALID_BODY: 'validation',
681
710
  NO_OPERATIONS: 'validation',
@@ -732,11 +761,14 @@ const ERROR_CATEGORIES = {
732
761
  INTERNAL_ERROR: 'internal',
733
762
  OPERATION_ERROR: 'internal',
734
763
  HTTP_ERROR: 'internal',
764
+ OPERATION_FAILED: 'internal',
765
+ USERINFO_FAILED: 'internal',
735
766
  }
736
767
 
737
768
  /**
738
769
  * Codes whose category is automatically retryable. Any other code is
739
- * one-shot: the caller must fix something before retrying.
770
+ * one-shot: the caller must fix something before retrying. A code missing
771
+ * from ERROR_CATEGORIES is retryable only when the server marks it so.
740
772
  */
741
773
  const RETRYABLE_CATEGORIES = new Set(['network', 'rate_limit', 'internal'])
742
774
 
@@ -777,7 +809,9 @@ export class SynthigyError extends Error {
777
809
  this.name = 'SynthigyError'
778
810
  this.code = code
779
811
  this.category = ERROR_CATEGORIES[code] ?? 'internal'
780
- this.retryable = RETRYABLE_CATEGORIES.has(this.category)
812
+ this.retryable = typeof extras?.retryable === 'boolean'
813
+ ? extras.retryable
814
+ : Object.hasOwn(ERROR_CATEGORIES, code) && RETRYABLE_CATEGORIES.has(this.category)
781
815
  if (details !== undefined) this.details = details
782
816
  if (extras) {
783
817
  if (extras.hint !== undefined) this.hint = extras.hint
@@ -833,6 +867,7 @@ function _errorFromServer(err, status, requestId) {
833
867
  start: err.start,
834
868
  end: err.end,
835
869
  diagnostics: err.diagnostics,
870
+ retryable: err.retryable,
836
871
  requestId,
837
872
  status,
838
873
  })
@@ -1968,6 +2003,19 @@ class SynthigyClient {
1968
2003
  return result.data
1969
2004
  }
1970
2005
 
2006
+ /**
2007
+ * Who a call runs as, after `actingAs`: xid, name, active, superuser, roles,
2008
+ * groups, and `client` ({ id, trusted }) — trusted means it may act as users.
2009
+ *
2010
+ * @param {Object} [opts] - Options ({ actingAs })
2011
+ * @returns {Promise<Object>}
2012
+ */
2013
+ async whoami(opts) {
2014
+ const [result] = await this.exec([{ op: 'whoami' }], opts)
2015
+ if (!result.ok) throw _errorFromServer(result.error, undefined, result._requestId)
2016
+ return result.data
2017
+ }
2018
+
1971
2019
  /**
1972
2020
  * Deploy a dataset version from a modeler export. Pass the export file's
1973
2021
  * contents verbatim — the server decodes it. Requires `dataset:deploy`.
@@ -2526,6 +2574,7 @@ export const op = {
2526
2574
  query: (xsql, params, verb = 'search') => ({ op: 'xsql', xsql: xsqlDocument(xsql, verb), ...(params !== undefined && { params }) }),
2527
2575
  deployedModel: () => ({ op: 'deployed-model' }),
2528
2576
  runtimeModel: () => ({ op: 'runtime-model' }),
2577
+ whoami: () => ({ op: 'whoami' }),
2529
2578
  deploy: (exportContents) => ({ op: 'deploy', data: exportContents }),
2530
2579
  destroy: (datasetXid) => ({ op: 'delete', entity: 'dataset', data: { xid: datasetXid } }),
2531
2580
  }
@@ -2657,6 +2706,7 @@ export const onboardComplete = (...a) => _dflt().onboardComplete(...a)
2657
2706
  export const deploy = (...a) => _dflt().deploy(...a)
2658
2707
  export const destroy = (...a) => _dflt().destroy(...a)
2659
2708
  export const deployedModel = (...a) => _dflt().deployedModel(...a)
2709
+ export const whoami = (...a) => _dflt().whoami(...a)
2660
2710
  export const runtimeModel = (...a) => _dflt().runtimeModel(...a)
2661
2711
  export const token = (...a) => _dflt().token(...a)
2662
2712
  export const history = () => _dflt().history
package/src/login.js CHANGED
@@ -13,7 +13,8 @@
13
13
 
14
14
  const PKCE_VERIFIER_BYTES = 32
15
15
  const STATE_BYTES = 24
16
- const DEFAULT_TTL_MS = 5 * 60_000
16
+ // must exceed the server's 8-minute login window, or slow logins fail LOGIN_STATE_UNKNOWN
17
+ const LOGIN_TTL_MS = 10 * 60_000
17
18
 
18
19
  function base64urlEncode(bytes) {
19
20
  let binary = ''
@@ -69,7 +70,7 @@ export function decodeJwtPayload(jwt) {
69
70
  * holds their sessions (Redis, Postgres, a signed cookie).
70
71
  */
71
72
  export class MemoryLoginStore {
72
- constructor({ ttlMs = DEFAULT_TTL_MS } = {}) {
73
+ constructor({ ttlMs = LOGIN_TTL_MS } = {}) {
73
74
  this._ttlMs = ttlMs
74
75
  this._pending = new Map()
75
76
  }
@@ -127,6 +128,12 @@ export function createLoginApi(client, { SynthigyError }) {
127
128
  }
128
129
  }
129
130
 
131
+ // the store's own expiry is not trusted: one that never expires would accept a login of any age
132
+ const takePending = async (state) => {
133
+ const pending = state ? await client._loginStore.take(state) : null
134
+ return pending && pending.createdAt > Date.now() - LOGIN_TTL_MS ? pending : null
135
+ }
136
+
130
137
  return {
131
138
  /**
132
139
  * Begin a login: returns the URL to redirect the browser to, and stashes
@@ -167,7 +174,7 @@ export function createLoginApi(client, { SynthigyError }) {
167
174
  */
168
175
  async complete({ code, state, redirectUri } = {}) {
169
176
  requireConfidential()
170
- const pending = state ? await client._loginStore.take(state) : null
177
+ const pending = await takePending(state)
171
178
  if (!pending) {
172
179
  throw new SynthigyError(
173
180
  'unknown or expired login state: the store never saw it, it was ' +
@@ -230,7 +237,7 @@ export function createLoginApi(client, { SynthigyError }) {
230
237
  * here rather than a throw.
231
238
  */
232
239
  async cancel(state) {
233
- const pending = state ? await client._loginStore.take(state) : null
240
+ const pending = await takePending(state)
234
241
  return pending ? { returnTo: pending.returnTo } : null
235
242
  },
236
243
  }