@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 +3 -1
- package/codegen/codegen.mjs +6 -13
- package/codegen/lib.mjs +24 -0
- package/codegen/pull.mjs +4 -4
- package/package.json +1 -1
- package/src/browser-login.js +2 -1
- package/src/index.d.ts +55 -1
- package/src/index.js +56 -6
- package/src/login.js +11 -4
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
|
|
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
|
package/codegen/codegen.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
25
|
-
|
|
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
|
|
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
package/src/browser-login.js
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
import { pkce, decodeJwtPayload } from './login.js'
|
|
15
15
|
|
|
16
16
|
const PENDING_PREFIX = 'synthigy.login:'
|
|
17
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
240
|
+
const pending = await takePending(state)
|
|
234
241
|
return pending ? { returnTo: pending.returnTo } : null
|
|
235
242
|
},
|
|
236
243
|
}
|