@synthigy/sdk 0.1.1 → 0.2.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 +113 -26
- package/codegen/codegen.mjs +185 -66
- package/codegen/pull.mjs +7 -1
- package/package.json +1 -1
- package/src/browser-login.js +309 -0
- package/src/index.d.ts +199 -5
- package/src/index.js +166 -21
- package/src/login.js +237 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser login as a PUBLIC client: authorization code + PKCE, silent renew
|
|
3
|
+
* through a hidden iframe, tokens in memory only. The same flow Synthigy's own
|
|
4
|
+
* tools use (the Clojure SDK's `synthigy.client.login`).
|
|
5
|
+
*
|
|
6
|
+
* The server-side, confidential flow is `client.login` (login.js). This one
|
|
7
|
+
* holds no secret: the code exchange is authenticated by the PKCE verifier.
|
|
8
|
+
* Nothing touches localStorage — only the in-flight login (verifier, nonce,
|
|
9
|
+
* return path) sits in sessionStorage between the redirect and the callback.
|
|
10
|
+
* A reload restores the session through silent renew, which rides the
|
|
11
|
+
* engine's own session cookie.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { pkce, decodeJwtPayload } from './login.js'
|
|
15
|
+
|
|
16
|
+
const PENDING_PREFIX = 'synthigy.login:'
|
|
17
|
+
const PENDING_TTL_MS = 5 * 60_000
|
|
18
|
+
const MESSAGE_SOURCE = 'synthigy-login'
|
|
19
|
+
|
|
20
|
+
function randomToken(bytes) {
|
|
21
|
+
let binary = ''
|
|
22
|
+
for (const b of globalThis.crypto.getRandomValues(new Uint8Array(bytes))) binary += String.fromCharCode(b)
|
|
23
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The authorization response `href` carries, or null. */
|
|
27
|
+
export function callbackParams(href) {
|
|
28
|
+
const q = new URL(href).searchParams
|
|
29
|
+
const state = q.get('state')
|
|
30
|
+
const code = q.get('code')
|
|
31
|
+
const error = q.get('error')
|
|
32
|
+
if (!state || !(code || error)) return null
|
|
33
|
+
return { state, code, error, errorDescription: q.get('error_description') }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Call first thing on the silent-redirect page. True when this window is a
|
|
38
|
+
* renewal iframe and has handed its URL to the parent — stop there.
|
|
39
|
+
*/
|
|
40
|
+
export function silentCallback() {
|
|
41
|
+
const w = globalThis.window
|
|
42
|
+
if (!w || w === w.parent || !callbackParams(globalThis.location.href)) return false
|
|
43
|
+
w.parent.postMessage({ source: MESSAGE_SOURCE, url: globalThis.location.href }, globalThis.location.origin)
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createBrowserLogin(config = {}, { SynthigyError }) {
|
|
48
|
+
const { endpoint, clientId, redirectUri } = config
|
|
49
|
+
if (!endpoint || !clientId || !redirectUri) {
|
|
50
|
+
throw new SynthigyError('createBrowserLogin needs endpoint, clientId and redirectUri', 'CONFIG_ERROR')
|
|
51
|
+
}
|
|
52
|
+
const cfg = {
|
|
53
|
+
scope: 'openid profile',
|
|
54
|
+
automaticSilentRenew: true,
|
|
55
|
+
loadUserInfo: false,
|
|
56
|
+
silentTimeoutMs: 10_000,
|
|
57
|
+
expiringSeconds: 60,
|
|
58
|
+
silentRedirectUri: redirectUri,
|
|
59
|
+
postLogoutRedirectUri: redirectUri,
|
|
60
|
+
...config,
|
|
61
|
+
endpoint: endpoint.replace(/\/+$/, ''),
|
|
62
|
+
}
|
|
63
|
+
const doFetch = cfg.fetch ?? globalThis.fetch.bind(globalThis)
|
|
64
|
+
const storage = cfg.storage ?? globalThis.sessionStorage
|
|
65
|
+
|
|
66
|
+
let tokens = null
|
|
67
|
+
let user = null
|
|
68
|
+
let renewal = null
|
|
69
|
+
let timers = []
|
|
70
|
+
const listeners = new Map()
|
|
71
|
+
|
|
72
|
+
const emit = (event, payload) => {
|
|
73
|
+
for (const f of listeners.get(event) ?? []) {
|
|
74
|
+
try { f(payload) } catch (e) { console.warn('synthigy browser login listener error:', e) }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const authorizeUrl = (request) => {
|
|
79
|
+
const params = new URLSearchParams()
|
|
80
|
+
for (const [k, v] of [
|
|
81
|
+
['response_type', 'code'],
|
|
82
|
+
['client_id', clientId],
|
|
83
|
+
['redirect_uri', request.redirectUri],
|
|
84
|
+
['scope', cfg.scope],
|
|
85
|
+
['state', request.state],
|
|
86
|
+
['nonce', request.nonce],
|
|
87
|
+
['code_challenge', request.codeChallenge],
|
|
88
|
+
['code_challenge_method', 'S256'],
|
|
89
|
+
['audience', cfg.audience],
|
|
90
|
+
['prompt', request.prompt],
|
|
91
|
+
]) if (v != null) params.append(k, v)
|
|
92
|
+
return `${cfg.endpoint}/oauth/authorize?${params}`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const startRequest = async (uri, prompt) => {
|
|
96
|
+
const { codeVerifier, codeChallenge } = await pkce()
|
|
97
|
+
return { redirectUri: uri, prompt, state: randomToken(24), nonce: randomToken(24), codeVerifier, codeChallenge }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const putPending = (request, returnTo) => storage.setItem(PENDING_PREFIX + request.state, JSON.stringify({
|
|
101
|
+
redirectUri: request.redirectUri, nonce: request.nonce, codeVerifier: request.codeVerifier,
|
|
102
|
+
returnTo, createdAt: Date.now(),
|
|
103
|
+
}))
|
|
104
|
+
|
|
105
|
+
const takePending = (state) => {
|
|
106
|
+
const key = PENDING_PREFIX + state
|
|
107
|
+
const raw = storage.getItem(key)
|
|
108
|
+
storage.removeItem(key)
|
|
109
|
+
if (!raw) return null
|
|
110
|
+
const pending = JSON.parse(raw)
|
|
111
|
+
return pending.createdAt > Date.now() - PENDING_TTL_MS ? pending : null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const stripCallback = () => {
|
|
115
|
+
const l = globalThis.location
|
|
116
|
+
globalThis.history.replaceState(null, '', l.pathname + l.hash)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const exchange = async (code, { redirectUri: uri, codeVerifier }) => {
|
|
120
|
+
const resp = await doFetch(`${cfg.endpoint}/oauth/token`, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
credentials: 'omit',
|
|
123
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
124
|
+
body: new URLSearchParams({
|
|
125
|
+
grant_type: 'authorization_code', code, redirect_uri: uri,
|
|
126
|
+
client_id: clientId, code_verifier: codeVerifier,
|
|
127
|
+
}).toString(),
|
|
128
|
+
})
|
|
129
|
+
const text = await resp.text()
|
|
130
|
+
if (!resp.ok) {
|
|
131
|
+
throw new SynthigyError(`authorization-code exchange failed (${resp.status})`,
|
|
132
|
+
'LOGIN_EXCHANGE_FAILED', text, { status: resp.status })
|
|
133
|
+
}
|
|
134
|
+
return JSON.parse(text)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const clearTimers = () => { timers.forEach(clearTimeout); timers = [] }
|
|
138
|
+
|
|
139
|
+
const schedule = (expiresAt) => {
|
|
140
|
+
clearTimers()
|
|
141
|
+
const now = Date.now()
|
|
142
|
+
timers = [
|
|
143
|
+
setTimeout(() => {
|
|
144
|
+
emit('accessTokenExpiring')
|
|
145
|
+
if (cfg.automaticSilentRenew) api.renew().catch(() => {})
|
|
146
|
+
}, Math.max(0, expiresAt - cfg.expiringSeconds * 1000 - now)),
|
|
147
|
+
setTimeout(() => emit('accessTokenExpired'), Math.max(0, expiresAt - now)),
|
|
148
|
+
]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const userInfo = async (accessToken) => {
|
|
152
|
+
const resp = await doFetch(`${cfg.endpoint}/oauth/userinfo`, {
|
|
153
|
+
method: 'GET',
|
|
154
|
+
credentials: 'omit',
|
|
155
|
+
headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` },
|
|
156
|
+
})
|
|
157
|
+
const text = await resp.text()
|
|
158
|
+
if (!resp.ok) {
|
|
159
|
+
throw new SynthigyError(`userinfo request failed (${resp.status})`, 'USERINFO_FAILED', text,
|
|
160
|
+
{ status: resp.status })
|
|
161
|
+
}
|
|
162
|
+
return JSON.parse(text)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const accept = async (tokenResponse, nonce) => {
|
|
166
|
+
const claims = decodeJwtPayload(tokenResponse.id_token)
|
|
167
|
+
if (!claims) {
|
|
168
|
+
throw new SynthigyError('token response carried no readable id_token — request the openid scope',
|
|
169
|
+
'LOGIN_EXCHANGE_FAILED')
|
|
170
|
+
}
|
|
171
|
+
if (claims.nonce !== nonce) {
|
|
172
|
+
throw new SynthigyError('id_token nonce does not match the one this login started with',
|
|
173
|
+
'LOGIN_NONCE_MISMATCH')
|
|
174
|
+
}
|
|
175
|
+
const info = cfg.loadUserInfo ? await userInfo(tokenResponse.access_token) : null
|
|
176
|
+
if (info && info.sub !== claims.sub) {
|
|
177
|
+
throw new SynthigyError('userinfo sub does not match the id_token', 'USERINFO_SUB_MISMATCH')
|
|
178
|
+
}
|
|
179
|
+
tokens = tokenResponse
|
|
180
|
+
user = { ...claims, ...info }
|
|
181
|
+
schedule(Date.now() + 1000 * (tokenResponse.expires_in ?? 300))
|
|
182
|
+
emit('userLoaded', user)
|
|
183
|
+
return user
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const authorizationError = ({ error, errorDescription }) =>
|
|
187
|
+
new SynthigyError(errorDescription || error, error)
|
|
188
|
+
|
|
189
|
+
const silentFrame = (request) => new Promise((resolve, reject) => {
|
|
190
|
+
const frame = globalThis.document.createElement('iframe')
|
|
191
|
+
const origin = new URL(cfg.silentRedirectUri).origin
|
|
192
|
+
frame.style.cssText = 'position:absolute;width:0;height:0;border:0;visibility:hidden'
|
|
193
|
+
let timer
|
|
194
|
+
const cleanup = () => {
|
|
195
|
+
clearTimeout(timer)
|
|
196
|
+
globalThis.window.removeEventListener('message', onMessage)
|
|
197
|
+
frame.remove()
|
|
198
|
+
}
|
|
199
|
+
const onMessage = (e) => {
|
|
200
|
+
if (e.origin !== origin || e.source !== frame.contentWindow || e.data?.source !== MESSAGE_SOURCE) return
|
|
201
|
+
cleanup()
|
|
202
|
+
const params = callbackParams(e.data.url)
|
|
203
|
+
if (params?.state === request.state) resolve(params)
|
|
204
|
+
else reject(new SynthigyError('silent renew answered with a foreign state', 'LOGIN_STATE_UNKNOWN'))
|
|
205
|
+
}
|
|
206
|
+
timer = setTimeout(() => {
|
|
207
|
+
cleanup()
|
|
208
|
+
reject(new SynthigyError('silent renew timed out', 'SILENT_TIMEOUT'))
|
|
209
|
+
}, cfg.silentTimeoutMs)
|
|
210
|
+
globalThis.window.addEventListener('message', onMessage)
|
|
211
|
+
frame.src = authorizeUrl(request)
|
|
212
|
+
globalThis.document.body.appendChild(frame)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
const api = {
|
|
216
|
+
get user() { return user },
|
|
217
|
+
get token() { return tokens?.access_token ?? null },
|
|
218
|
+
|
|
219
|
+
/** Subscribe to userLoaded, userUnloaded, accessTokenExpiring, accessTokenExpired or silentRenewError. */
|
|
220
|
+
on(event, f) {
|
|
221
|
+
if (!listeners.has(event)) listeners.set(event, new Set())
|
|
222
|
+
listeners.get(event).add(f)
|
|
223
|
+
return () => listeners.get(event).delete(f)
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
/** True when the current URL is an authorization response. */
|
|
227
|
+
isCallback: () => callbackParams(globalThis.location.href) !== null,
|
|
228
|
+
|
|
229
|
+
/** Redirect to the login page. `prompt: 'none'` comes straight back when a session is alive. */
|
|
230
|
+
async start({ prompt, returnTo } = {}) {
|
|
231
|
+
const l = globalThis.location
|
|
232
|
+
const request = await startRequest(redirectUri, prompt)
|
|
233
|
+
putPending(request, returnTo ?? l.pathname + l.search + l.hash)
|
|
234
|
+
l.assign(authorizeUrl(request))
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
/** Finish the login the current URL returns from: `{ user, returnTo }`, or null when it carries none. */
|
|
238
|
+
async complete() {
|
|
239
|
+
const params = callbackParams(globalThis.location.href)
|
|
240
|
+
if (!params) return null
|
|
241
|
+
const pending = takePending(params.state)
|
|
242
|
+
stripCallback()
|
|
243
|
+
if (!pending) {
|
|
244
|
+
throw new SynthigyError('unknown or expired login state: already consumed, timed out, or started in another tab',
|
|
245
|
+
'LOGIN_STATE_UNKNOWN')
|
|
246
|
+
}
|
|
247
|
+
if (!params.code) throw authorizationError(params)
|
|
248
|
+
const tokenResponse = await exchange(params.code, pending)
|
|
249
|
+
return { user: await accept(tokenResponse, pending.nonce), returnTo: pending.returnTo }
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
/** Drop the login the current URL returns from (the user cancelled): `{ returnTo }` or null. */
|
|
253
|
+
cancel() {
|
|
254
|
+
const params = callbackParams(globalThis.location.href)
|
|
255
|
+
if (!params) return null
|
|
256
|
+
stripCallback()
|
|
257
|
+
const pending = takePending(params.state)
|
|
258
|
+
return pending ? { returnTo: pending.returnTo } : null
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
/** Silent renew through a hidden iframe (prompt=none); one renewal at a time. */
|
|
262
|
+
renew() {
|
|
263
|
+
if (renewal) return renewal
|
|
264
|
+
renewal = (async () => {
|
|
265
|
+
const request = await startRequest(cfg.silentRedirectUri, 'none')
|
|
266
|
+
const params = await silentFrame(request)
|
|
267
|
+
if (!params.code) throw authorizationError(params)
|
|
268
|
+
return await accept(await exchange(params.code, request), request.nonce)
|
|
269
|
+
})()
|
|
270
|
+
.catch((e) => { emit('silentRenewError', e); throw e })
|
|
271
|
+
.finally(() => { renewal = null })
|
|
272
|
+
return renewal
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
/** Forget the tokens here without ending the server session. */
|
|
276
|
+
removeUser() {
|
|
277
|
+
clearTimers()
|
|
278
|
+
tokens = null
|
|
279
|
+
user = null
|
|
280
|
+
emit('userUnloaded')
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
/** End the server session and go to postLogoutRedirectUri. */
|
|
284
|
+
logout() {
|
|
285
|
+
const params = new URLSearchParams()
|
|
286
|
+
if (tokens?.id_token) params.append('id_token_hint', tokens.id_token)
|
|
287
|
+
params.append('post_logout_redirect_uri', cfg.postLogoutRedirectUri)
|
|
288
|
+
api.removeUser()
|
|
289
|
+
globalThis.location.assign(`${cfg.endpoint}/oauth/logout?${params}`)
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* For `createClient({ tokenProvider })`. A 401 clears → starts a renewal,
|
|
294
|
+
* and the retried request waits for it.
|
|
295
|
+
*/
|
|
296
|
+
tokenProvider() {
|
|
297
|
+
return {
|
|
298
|
+
async getToken() {
|
|
299
|
+
if (renewal) await renewal.catch(() => {})
|
|
300
|
+
return api.token
|
|
301
|
+
},
|
|
302
|
+
clear() {
|
|
303
|
+
api.renew().catch(() => {})
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
return api
|
|
309
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/** The platform API this SDK is a client of; the default audience for every mint. */
|
|
2
|
+
export const PLATFORM_AUDIENCE: "https://synthigy.com"
|
|
3
|
+
|
|
1
4
|
// ============================================================================
|
|
2
5
|
// Selection
|
|
3
6
|
// ============================================================================
|
|
@@ -79,6 +82,13 @@ export interface TreeExecOpts extends ExecOpts {
|
|
|
79
82
|
childrenKey?: string
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
/** What `deploy()` acks with — `dataset` is the xid `destroy()` takes. */
|
|
86
|
+
export interface DeployAck {
|
|
87
|
+
deployed: boolean
|
|
88
|
+
version: string
|
|
89
|
+
dataset: string
|
|
90
|
+
}
|
|
91
|
+
|
|
82
92
|
export type OperationResult<T = unknown> =
|
|
83
93
|
| { ok: true; data: T }
|
|
84
94
|
| { ok: false; error: { message: string; code: string } }
|
|
@@ -284,11 +294,16 @@ export interface Schema {
|
|
|
284
294
|
*
|
|
285
295
|
* Client-side (no server round trip):
|
|
286
296
|
* NO_TOKEN — no token source configured; see ClientConfig.
|
|
297
|
+
* NO_LOGIN_STORE — createClient({ loginStore }) was never given one.
|
|
298
|
+
* LOGIN_REQUIRES_CONFIDENTIAL_CLIENT — login needs clientId + clientSecret.
|
|
299
|
+
* LOGIN_STATE_UNKNOWN, LOGIN_NONCE_MISMATCH, LOGIN_EXCHANGE_FAILED — see client.login.
|
|
287
300
|
*/
|
|
288
301
|
export type SynthigyErrorCode =
|
|
289
302
|
| 'UNAUTHORIZED' | 'CLIENT_NOT_FOUND' | 'CLIENT_INACTIVE'
|
|
290
303
|
| 'PUBLIC_CLIENT_FORBIDDEN' | 'NOT_TRUSTED' | 'USER_NOT_FOUND'
|
|
291
304
|
| 'FORBIDDEN' | 'NO_TOKEN'
|
|
305
|
+
| 'NO_LOGIN_STORE' | 'LOGIN_REQUIRES_CONFIDENTIAL_CLIENT'
|
|
306
|
+
| 'LOGIN_STATE_UNKNOWN' | 'LOGIN_NONCE_MISMATCH' | 'LOGIN_EXCHANGE_FAILED'
|
|
292
307
|
| 'INVALID_BODY' | 'NO_OPERATIONS' | 'BAD_OP' | 'MISSING_ENTITY'
|
|
293
308
|
| 'BAD_ARGS_SHAPE'
|
|
294
309
|
| 'UNKNOWN_OP' | 'UNKNOWN_ENTITY' | 'UNKNOWN_ATTRIBUTE' | 'UNKNOWN_OPERATOR'
|
|
@@ -311,7 +326,8 @@ export class SynthigyError extends Error {
|
|
|
311
326
|
// ============================================================================
|
|
312
327
|
|
|
313
328
|
export interface ClientConfig {
|
|
314
|
-
|
|
329
|
+
/** Synthigy base URL. Defaults to `$SYNTHIGY_ENDPOINT` (set by `synthigy exec`). */
|
|
330
|
+
endpoint?: string
|
|
315
331
|
/** OAuth client ID — required with clientSecret for client credentials */
|
|
316
332
|
clientId?: string
|
|
317
333
|
clientSecret?: string
|
|
@@ -322,6 +338,12 @@ export interface ClientConfig {
|
|
|
322
338
|
* with no source at all it throws SynthigyError{code: 'NO_TOKEN'}.
|
|
323
339
|
*/
|
|
324
340
|
token?: string
|
|
341
|
+
/**
|
|
342
|
+
* Caller-owned token source, checked before every other option. `clear()`
|
|
343
|
+
* is called on a 401 before the single retry; `createBrowserLogin()`'s
|
|
344
|
+
* `tokenProvider()` renews there.
|
|
345
|
+
*/
|
|
346
|
+
tokenProvider?: TokenProvider
|
|
325
347
|
scope?: string
|
|
326
348
|
/**
|
|
327
349
|
* Default client_credentials audience, applied to every mint this client
|
|
@@ -358,6 +380,131 @@ export interface ClientConfig {
|
|
|
358
380
|
* Pass `undici.fetch` with a shared `Agent` for pooled connections in Node.
|
|
359
381
|
*/
|
|
360
382
|
fetch?: typeof globalThis.fetch
|
|
383
|
+
/**
|
|
384
|
+
* Where in-flight logins live between `login.start()` and the callback.
|
|
385
|
+
* Required to use `client.login` at all — without one, every call throws
|
|
386
|
+
* `NO_LOGIN_STORE` rather than silently keeping state in one process.
|
|
387
|
+
* `MemoryLoginStore` is the single-process dev option.
|
|
388
|
+
*/
|
|
389
|
+
loginStore?: LoginStore
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ============================================================================
|
|
393
|
+
// Login — OIDC authorization-code + PKCE
|
|
394
|
+
// ============================================================================
|
|
395
|
+
|
|
396
|
+
/** One login in flight: everything the callback needs to finish it. */
|
|
397
|
+
export interface PendingLogin {
|
|
398
|
+
codeVerifier: string
|
|
399
|
+
nonce: string
|
|
400
|
+
returnTo: string
|
|
401
|
+
createdAt: number
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The one piece of protocol state the SDK cannot hold for you.
|
|
406
|
+
*
|
|
407
|
+
* `take` is one-shot: it must return the login AND remove it, so a replayed
|
|
408
|
+
* callback finds nothing. Both methods may be sync or async.
|
|
409
|
+
*/
|
|
410
|
+
export interface TokenProvider {
|
|
411
|
+
getToken(audience?: string): string | null | Promise<string | null>
|
|
412
|
+
clear(): void
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export interface BrowserLoginConfig {
|
|
416
|
+
endpoint: string
|
|
417
|
+
clientId: string
|
|
418
|
+
/** The page that finishes the login (calls `complete()`), registered on the client. */
|
|
419
|
+
redirectUri: string
|
|
420
|
+
/** Page the renewal iframe lands on; default `redirectUri`. It must call `silentCallback()` first. */
|
|
421
|
+
silentRedirectUri?: string
|
|
422
|
+
postLogoutRedirectUri?: string
|
|
423
|
+
/** Default 'openid profile'. */
|
|
424
|
+
scope?: string
|
|
425
|
+
audience?: string
|
|
426
|
+
/** Renew silently shortly before expiry (default true). */
|
|
427
|
+
automaticSilentRenew?: boolean
|
|
428
|
+
/** Merge /oauth/userinfo into `user` after each login and renewal (default false) — the profile (name, email) lives there. */
|
|
429
|
+
loadUserInfo?: boolean
|
|
430
|
+
/** Seconds before expiry that renewal starts (default 60). */
|
|
431
|
+
expiringSeconds?: number
|
|
432
|
+
silentTimeoutMs?: number
|
|
433
|
+
fetch?: typeof globalThis.fetch
|
|
434
|
+
/** Where the in-flight login waits for the callback (default sessionStorage). */
|
|
435
|
+
storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export type BrowserLoginEvent =
|
|
439
|
+
'userLoaded' | 'userUnloaded' | 'accessTokenExpiring' | 'accessTokenExpired' | 'silentRenewError'
|
|
440
|
+
|
|
441
|
+
export interface BrowserLogin {
|
|
442
|
+
/** id_token claims of the logged-in user (`sub`, `xid`, …), or null. */
|
|
443
|
+
readonly user: Record<string, unknown> | null
|
|
444
|
+
readonly token: string | null
|
|
445
|
+
on(event: BrowserLoginEvent, listener: (payload?: unknown) => void): () => void
|
|
446
|
+
isCallback(): boolean
|
|
447
|
+
/** Redirect to the login page; `prompt: 'none'` returns at once when a session is alive. */
|
|
448
|
+
start(opts?: { prompt?: 'none' | 'login'; returnTo?: string }): Promise<void>
|
|
449
|
+
/** Finish the login the current URL returns from, or null when it carries none. */
|
|
450
|
+
complete(): Promise<{ user: Record<string, unknown>; returnTo: string } | null>
|
|
451
|
+
/** The user cancelled on the IdP: drop the pending login. */
|
|
452
|
+
cancel(): { returnTo: string } | null
|
|
453
|
+
renew(): Promise<Record<string, unknown>>
|
|
454
|
+
removeUser(): void
|
|
455
|
+
logout(): void
|
|
456
|
+
tokenProvider(): TokenProvider
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Browser login as a public client: PKCE, silent renew, tokens in memory. */
|
|
460
|
+
export function createBrowserLogin(config: BrowserLoginConfig): BrowserLogin
|
|
461
|
+
|
|
462
|
+
/** Call first on the silent-redirect page; true when this window was a renewal iframe and answered it. */
|
|
463
|
+
export function silentCallback(): boolean
|
|
464
|
+
|
|
465
|
+
export interface LoginStore {
|
|
466
|
+
put(state: string, login: PendingLogin): void | Promise<void>
|
|
467
|
+
take(state: string): PendingLogin | null | Promise<PendingLogin | null>
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** In-memory `LoginStore`. Single process only — see the class docstring. */
|
|
471
|
+
export class MemoryLoginStore implements LoginStore {
|
|
472
|
+
constructor(opts?: { ttlMs?: number })
|
|
473
|
+
put(state: string, login: PendingLogin): void
|
|
474
|
+
take(state: string): PendingLogin | null
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export interface LoginStartOpts {
|
|
478
|
+
/** URL on YOUR server the IdP redirects back to; must be registered on the client. */
|
|
479
|
+
redirectUri: string
|
|
480
|
+
/** Where to send the visitor once login finishes. Default `'/'`. */
|
|
481
|
+
returnTo?: string
|
|
482
|
+
/** Default `'openid'` — the scope that names the user. */
|
|
483
|
+
scope?: string
|
|
484
|
+
/** Browser-reachable engine URL, when it differs from the server-side one. */
|
|
485
|
+
publicEndpoint?: string
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export interface LoginCompleteOpts {
|
|
489
|
+
code: string
|
|
490
|
+
state: string
|
|
491
|
+
redirectUri: string
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export interface LoginTokens {
|
|
495
|
+
access_token: string
|
|
496
|
+
id_token?: string
|
|
497
|
+
refresh_token?: string
|
|
498
|
+
token_type?: string
|
|
499
|
+
scope?: string
|
|
500
|
+
expires_in?: number
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export interface LoginResult {
|
|
504
|
+
/** Straight out of the id_token — no /data round-trip. */
|
|
505
|
+
user: { xid: string | null; name: string | null; scopes: string[] }
|
|
506
|
+
tokens: LoginTokens
|
|
507
|
+
returnTo: string
|
|
361
508
|
}
|
|
362
509
|
|
|
363
510
|
// ============================================================================
|
|
@@ -556,6 +703,19 @@ export class SynthigyClient {
|
|
|
556
703
|
since(opts?: HistorySinceOpts): Promise<HistoryEvent[]>
|
|
557
704
|
}
|
|
558
705
|
|
|
706
|
+
// ── Login — OIDC authorization-code + PKCE ───────────────────────────────
|
|
707
|
+
|
|
708
|
+
login: {
|
|
709
|
+
/** Begin a login; redirect the browser to the returned URL. */
|
|
710
|
+
start(opts: LoginStartOpts): Promise<{ url: string }>
|
|
711
|
+
|
|
712
|
+
/** Finish a login from the callback's `code` + `state`. */
|
|
713
|
+
complete(opts: LoginCompleteOpts): Promise<LoginResult>
|
|
714
|
+
|
|
715
|
+
/** Drop an in-flight login and recover its `returnTo`; null when unknown. */
|
|
716
|
+
cancel(state: string | null | undefined): Promise<{ returnTo: string } | null>
|
|
717
|
+
}
|
|
718
|
+
|
|
559
719
|
/** Fetch the IAM-filtered model schema via `GET /schema`. Pass entity names to filter. */
|
|
560
720
|
schema(entities?: string[]): Promise<Schema>
|
|
561
721
|
|
|
@@ -568,6 +728,17 @@ export class SynthigyClient {
|
|
|
568
728
|
op?: 'search' | 'get' | 'slice' | 'purge'
|
|
569
729
|
}): Promise<LintDiagnostic[]>
|
|
570
730
|
|
|
731
|
+
/**
|
|
732
|
+
* Compile an XSQL source to the wire operation the engine would execute
|
|
733
|
+
* (`POST /compile`), without executing it. `params` bind exactly as on
|
|
734
|
+
* `query`, so the result IS what the engine receives — edit it and send
|
|
735
|
+
* it through `exec`.
|
|
736
|
+
*/
|
|
737
|
+
compile(source: string, opts?: {
|
|
738
|
+
params?: Record<string, unknown>
|
|
739
|
+
op?: 'search' | 'get' | 'slice' | 'purge'
|
|
740
|
+
}): Promise<Operation>
|
|
741
|
+
|
|
571
742
|
/**
|
|
572
743
|
* Mint a one-time account-claim link via `POST /oauth/onboard`.
|
|
573
744
|
* Confidential client whose principal administers the account — RBAC
|
|
@@ -608,6 +779,19 @@ export class SynthigyClient {
|
|
|
608
779
|
*/
|
|
609
780
|
deployedModel(opts?: ExecOpts): Promise<Record<string, unknown>>
|
|
610
781
|
|
|
782
|
+
/**
|
|
783
|
+
* Deploy a dataset version from a modeler export — pass the export file's
|
|
784
|
+
* contents verbatim, the server decodes it. Requires `dataset:deploy`.
|
|
785
|
+
* The ack carries the dataset xid `destroy()` takes.
|
|
786
|
+
*/
|
|
787
|
+
deploy(exportContents: string, opts?: ExecOpts): Promise<DeployAck>
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Destroy a dataset — every version, table and row. Requires
|
|
791
|
+
* `dataset:delete`. Idempotent.
|
|
792
|
+
*/
|
|
793
|
+
destroy(datasetXid: string, opts?: ExecOpts): Promise<boolean>
|
|
794
|
+
|
|
611
795
|
/**
|
|
612
796
|
* Fetch the runtime (enhanced) ERD model via `op: "runtime-model"` —
|
|
613
797
|
* the model as the running system actually emits it, with audit
|
|
@@ -1004,16 +1188,20 @@ export function rel(selections: Selection, args?: Args, alias?: string): Relatio
|
|
|
1004
1188
|
export const op: {
|
|
1005
1189
|
search(entity: string, args: Args | undefined, selection: Selection): Operation
|
|
1006
1190
|
get(entity: string, args: Record<string, unknown>, selection: Selection): Operation
|
|
1007
|
-
sync(entity: string, data: Record<string, unknown
|
|
1008
|
-
stack(entity: string, data: Record<string, unknown
|
|
1191
|
+
sync(entity: string, data: Record<string, unknown> | Record<string, unknown>[], returning?: boolean): Operation
|
|
1192
|
+
stack(entity: string, data: Record<string, unknown> | Record<string, unknown>[], returning?: boolean): Operation
|
|
1009
1193
|
slice(entity: string, args: Record<string, unknown>, selection?: Selection): Operation
|
|
1010
1194
|
delete(entity: string, data: Record<string, unknown>): Operation
|
|
1011
1195
|
purge(entity: string, args: Args | undefined, selection?: Selection): Operation
|
|
1012
1196
|
searchTree(entity: string, on: string, args: Args | undefined, selection: Selection): Operation
|
|
1013
1197
|
getTree(entity: string, root: string, on: string, selection: Selection): Operation
|
|
1014
1198
|
sqlTemplate(template: string, params?: Record<string, unknown> | unknown[], opts?: { cached?: boolean }): Operation
|
|
1199
|
+
/** An XSQL read, for `exec` batches; `verb` names the op a bare body runs as (default `search`). */
|
|
1200
|
+
query(xsql: string, params?: Record<string, unknown>, verb?: string): Operation
|
|
1015
1201
|
deployedModel(): Operation
|
|
1016
1202
|
runtimeModel(): Operation
|
|
1203
|
+
deploy(exportContents: string): Operation
|
|
1204
|
+
destroy(datasetXid: string): Operation
|
|
1017
1205
|
}
|
|
1018
1206
|
|
|
1019
1207
|
// ============================================================================
|
|
@@ -1049,7 +1237,7 @@ export function composeForest<T extends Record<string, unknown>>(
|
|
|
1049
1237
|
// Factory
|
|
1050
1238
|
// ============================================================================
|
|
1051
1239
|
|
|
1052
|
-
export function createClient(config
|
|
1240
|
+
export function createClient(config?: ClientConfig): SynthigyClient
|
|
1053
1241
|
|
|
1054
1242
|
// ============================================================================
|
|
1055
1243
|
// Single-client layer — connect once, call the bare verbs, no client threading.
|
|
@@ -1058,7 +1246,7 @@ export function createClient(config: ClientConfig): SynthigyClient
|
|
|
1058
1246
|
// ============================================================================
|
|
1059
1247
|
|
|
1060
1248
|
/** Install a module-wide default client (destroys any previous one). */
|
|
1061
|
-
export function connect(config
|
|
1249
|
+
export function connect(config?: ClientConfig): SynthigyClient
|
|
1062
1250
|
/** Destroy and uninstall the default client. No-op when not connected. */
|
|
1063
1251
|
export function disconnect(): void
|
|
1064
1252
|
/** The default client installed by `connect`, or null. */
|
|
@@ -1087,10 +1275,16 @@ export { _delete as delete }
|
|
|
1087
1275
|
|
|
1088
1276
|
export const schema: SynthigyClient['schema']
|
|
1089
1277
|
export const lint: SynthigyClient['lint']
|
|
1278
|
+
export const compile: SynthigyClient['compile']
|
|
1279
|
+
export const onboard: SynthigyClient['onboard']
|
|
1280
|
+
export const onboardComplete: SynthigyClient['onboardComplete']
|
|
1281
|
+
export const deploy: SynthigyClient['deploy']
|
|
1282
|
+
export const destroy: SynthigyClient['destroy']
|
|
1090
1283
|
export const deployedModel: SynthigyClient['deployedModel']
|
|
1091
1284
|
export const runtimeModel: SynthigyClient['runtimeModel']
|
|
1092
1285
|
export const token: SynthigyClient['token']
|
|
1093
1286
|
export function history(): SynthigyClient['history']
|
|
1287
|
+
export function login(): SynthigyClient['login']
|
|
1094
1288
|
|
|
1095
1289
|
export const listen: SynthigyClient['listen']
|
|
1096
1290
|
export const observe: SynthigyClient['observe']
|