@synthigy/sdk 0.1.2 → 0.2.1

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.
@@ -0,0 +1,310 @@
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
+ // must exceed the server's 8-minute login window, or slow logins fail LOGIN_STATE_UNKNOWN
18
+ const PENDING_TTL_MS = 10 * 60_000
19
+ const MESSAGE_SOURCE = 'synthigy-login'
20
+
21
+ function randomToken(bytes) {
22
+ let binary = ''
23
+ for (const b of globalThis.crypto.getRandomValues(new Uint8Array(bytes))) binary += String.fromCharCode(b)
24
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
25
+ }
26
+
27
+ /** The authorization response `href` carries, or null. */
28
+ export function callbackParams(href) {
29
+ const q = new URL(href).searchParams
30
+ const state = q.get('state')
31
+ const code = q.get('code')
32
+ const error = q.get('error')
33
+ if (!state || !(code || error)) return null
34
+ return { state, code, error, errorDescription: q.get('error_description') }
35
+ }
36
+
37
+ /**
38
+ * Call first thing on the silent-redirect page. True when this window is a
39
+ * renewal iframe and has handed its URL to the parent — stop there.
40
+ */
41
+ export function silentCallback() {
42
+ const w = globalThis.window
43
+ if (!w || w === w.parent || !callbackParams(globalThis.location.href)) return false
44
+ w.parent.postMessage({ source: MESSAGE_SOURCE, url: globalThis.location.href }, globalThis.location.origin)
45
+ return true
46
+ }
47
+
48
+ export function createBrowserLogin(config = {}, { SynthigyError }) {
49
+ const { endpoint, clientId, redirectUri } = config
50
+ if (!endpoint || !clientId || !redirectUri) {
51
+ throw new SynthigyError('createBrowserLogin needs endpoint, clientId and redirectUri', 'CONFIG_ERROR')
52
+ }
53
+ const cfg = {
54
+ scope: 'openid profile',
55
+ automaticSilentRenew: true,
56
+ loadUserInfo: false,
57
+ silentTimeoutMs: 10_000,
58
+ expiringSeconds: 60,
59
+ silentRedirectUri: redirectUri,
60
+ postLogoutRedirectUri: redirectUri,
61
+ ...config,
62
+ endpoint: endpoint.replace(/\/+$/, ''),
63
+ }
64
+ const doFetch = cfg.fetch ?? globalThis.fetch.bind(globalThis)
65
+ const storage = cfg.storage ?? globalThis.sessionStorage
66
+
67
+ let tokens = null
68
+ let user = null
69
+ let renewal = null
70
+ let timers = []
71
+ const listeners = new Map()
72
+
73
+ const emit = (event, payload) => {
74
+ for (const f of listeners.get(event) ?? []) {
75
+ try { f(payload) } catch (e) { console.warn('synthigy browser login listener error:', e) }
76
+ }
77
+ }
78
+
79
+ const authorizeUrl = (request) => {
80
+ const params = new URLSearchParams()
81
+ for (const [k, v] of [
82
+ ['response_type', 'code'],
83
+ ['client_id', clientId],
84
+ ['redirect_uri', request.redirectUri],
85
+ ['scope', cfg.scope],
86
+ ['state', request.state],
87
+ ['nonce', request.nonce],
88
+ ['code_challenge', request.codeChallenge],
89
+ ['code_challenge_method', 'S256'],
90
+ ['audience', cfg.audience],
91
+ ['prompt', request.prompt],
92
+ ]) if (v != null) params.append(k, v)
93
+ return `${cfg.endpoint}/oauth/authorize?${params}`
94
+ }
95
+
96
+ const startRequest = async (uri, prompt) => {
97
+ const { codeVerifier, codeChallenge } = await pkce()
98
+ return { redirectUri: uri, prompt, state: randomToken(24), nonce: randomToken(24), codeVerifier, codeChallenge }
99
+ }
100
+
101
+ const putPending = (request, returnTo) => storage.setItem(PENDING_PREFIX + request.state, JSON.stringify({
102
+ redirectUri: request.redirectUri, nonce: request.nonce, codeVerifier: request.codeVerifier,
103
+ returnTo, createdAt: Date.now(),
104
+ }))
105
+
106
+ const takePending = (state) => {
107
+ const key = PENDING_PREFIX + state
108
+ const raw = storage.getItem(key)
109
+ storage.removeItem(key)
110
+ if (!raw) return null
111
+ const pending = JSON.parse(raw)
112
+ return pending.createdAt > Date.now() - PENDING_TTL_MS ? pending : null
113
+ }
114
+
115
+ const stripCallback = () => {
116
+ const l = globalThis.location
117
+ globalThis.history.replaceState(null, '', l.pathname + l.hash)
118
+ }
119
+
120
+ const exchange = async (code, { redirectUri: uri, codeVerifier }) => {
121
+ const resp = await doFetch(`${cfg.endpoint}/oauth/token`, {
122
+ method: 'POST',
123
+ credentials: 'omit',
124
+ headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
125
+ body: new URLSearchParams({
126
+ grant_type: 'authorization_code', code, redirect_uri: uri,
127
+ client_id: clientId, code_verifier: codeVerifier,
128
+ }).toString(),
129
+ })
130
+ const text = await resp.text()
131
+ if (!resp.ok) {
132
+ throw new SynthigyError(`authorization-code exchange failed (${resp.status})`,
133
+ 'LOGIN_EXCHANGE_FAILED', text, { status: resp.status })
134
+ }
135
+ return JSON.parse(text)
136
+ }
137
+
138
+ const clearTimers = () => { timers.forEach(clearTimeout); timers = [] }
139
+
140
+ const schedule = (expiresAt) => {
141
+ clearTimers()
142
+ const now = Date.now()
143
+ timers = [
144
+ setTimeout(() => {
145
+ emit('accessTokenExpiring')
146
+ if (cfg.automaticSilentRenew) api.renew().catch(() => {})
147
+ }, Math.max(0, expiresAt - cfg.expiringSeconds * 1000 - now)),
148
+ setTimeout(() => emit('accessTokenExpired'), Math.max(0, expiresAt - now)),
149
+ ]
150
+ }
151
+
152
+ const userInfo = async (accessToken) => {
153
+ const resp = await doFetch(`${cfg.endpoint}/oauth/userinfo`, {
154
+ method: 'GET',
155
+ credentials: 'omit',
156
+ headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` },
157
+ })
158
+ const text = await resp.text()
159
+ if (!resp.ok) {
160
+ throw new SynthigyError(`userinfo request failed (${resp.status})`, 'USERINFO_FAILED', text,
161
+ { status: resp.status })
162
+ }
163
+ return JSON.parse(text)
164
+ }
165
+
166
+ const accept = async (tokenResponse, nonce) => {
167
+ const claims = decodeJwtPayload(tokenResponse.id_token)
168
+ if (!claims) {
169
+ throw new SynthigyError('token response carried no readable id_token — request the openid scope',
170
+ 'LOGIN_EXCHANGE_FAILED')
171
+ }
172
+ if (claims.nonce !== nonce) {
173
+ throw new SynthigyError('id_token nonce does not match the one this login started with',
174
+ 'LOGIN_NONCE_MISMATCH')
175
+ }
176
+ const info = cfg.loadUserInfo ? await userInfo(tokenResponse.access_token) : null
177
+ if (info && info.sub !== claims.sub) {
178
+ throw new SynthigyError('userinfo sub does not match the id_token', 'USERINFO_SUB_MISMATCH')
179
+ }
180
+ tokens = tokenResponse
181
+ user = { ...claims, ...info }
182
+ schedule(Date.now() + 1000 * (tokenResponse.expires_in ?? 300))
183
+ emit('userLoaded', user)
184
+ return user
185
+ }
186
+
187
+ const authorizationError = ({ error, errorDescription }) =>
188
+ new SynthigyError(errorDescription || error, error)
189
+
190
+ const silentFrame = (request) => new Promise((resolve, reject) => {
191
+ const frame = globalThis.document.createElement('iframe')
192
+ const origin = new URL(cfg.silentRedirectUri).origin
193
+ frame.style.cssText = 'position:absolute;width:0;height:0;border:0;visibility:hidden'
194
+ let timer
195
+ const cleanup = () => {
196
+ clearTimeout(timer)
197
+ globalThis.window.removeEventListener('message', onMessage)
198
+ frame.remove()
199
+ }
200
+ const onMessage = (e) => {
201
+ if (e.origin !== origin || e.source !== frame.contentWindow || e.data?.source !== MESSAGE_SOURCE) return
202
+ cleanup()
203
+ const params = callbackParams(e.data.url)
204
+ if (params?.state === request.state) resolve(params)
205
+ else reject(new SynthigyError('silent renew answered with a foreign state', 'LOGIN_STATE_UNKNOWN'))
206
+ }
207
+ timer = setTimeout(() => {
208
+ cleanup()
209
+ reject(new SynthigyError('silent renew timed out', 'SILENT_TIMEOUT'))
210
+ }, cfg.silentTimeoutMs)
211
+ globalThis.window.addEventListener('message', onMessage)
212
+ frame.src = authorizeUrl(request)
213
+ globalThis.document.body.appendChild(frame)
214
+ })
215
+
216
+ const api = {
217
+ get user() { return user },
218
+ get token() { return tokens?.access_token ?? null },
219
+
220
+ /** Subscribe to userLoaded, userUnloaded, accessTokenExpiring, accessTokenExpired or silentRenewError. */
221
+ on(event, f) {
222
+ if (!listeners.has(event)) listeners.set(event, new Set())
223
+ listeners.get(event).add(f)
224
+ return () => listeners.get(event).delete(f)
225
+ },
226
+
227
+ /** True when the current URL is an authorization response. */
228
+ isCallback: () => callbackParams(globalThis.location.href) !== null,
229
+
230
+ /** Redirect to the login page. `prompt: 'none'` comes straight back when a session is alive. */
231
+ async start({ prompt, returnTo } = {}) {
232
+ const l = globalThis.location
233
+ const request = await startRequest(redirectUri, prompt)
234
+ putPending(request, returnTo ?? l.pathname + l.search + l.hash)
235
+ l.assign(authorizeUrl(request))
236
+ },
237
+
238
+ /** Finish the login the current URL returns from: `{ user, returnTo }`, or null when it carries none. */
239
+ async complete() {
240
+ const params = callbackParams(globalThis.location.href)
241
+ if (!params) return null
242
+ const pending = takePending(params.state)
243
+ stripCallback()
244
+ if (!pending) {
245
+ throw new SynthigyError('unknown or expired login state: already consumed, timed out, or started in another tab',
246
+ 'LOGIN_STATE_UNKNOWN')
247
+ }
248
+ if (!params.code) throw authorizationError(params)
249
+ const tokenResponse = await exchange(params.code, pending)
250
+ return { user: await accept(tokenResponse, pending.nonce), returnTo: pending.returnTo }
251
+ },
252
+
253
+ /** Drop the login the current URL returns from (the user cancelled): `{ returnTo }` or null. */
254
+ cancel() {
255
+ const params = callbackParams(globalThis.location.href)
256
+ if (!params) return null
257
+ stripCallback()
258
+ const pending = takePending(params.state)
259
+ return pending ? { returnTo: pending.returnTo } : null
260
+ },
261
+
262
+ /** Silent renew through a hidden iframe (prompt=none); one renewal at a time. */
263
+ renew() {
264
+ if (renewal) return renewal
265
+ renewal = (async () => {
266
+ const request = await startRequest(cfg.silentRedirectUri, 'none')
267
+ const params = await silentFrame(request)
268
+ if (!params.code) throw authorizationError(params)
269
+ return await accept(await exchange(params.code, request), request.nonce)
270
+ })()
271
+ .catch((e) => { emit('silentRenewError', e); throw e })
272
+ .finally(() => { renewal = null })
273
+ return renewal
274
+ },
275
+
276
+ /** Forget the tokens here without ending the server session. */
277
+ removeUser() {
278
+ clearTimers()
279
+ tokens = null
280
+ user = null
281
+ emit('userUnloaded')
282
+ },
283
+
284
+ /** End the server session and go to postLogoutRedirectUri. */
285
+ logout() {
286
+ const params = new URLSearchParams()
287
+ if (tokens?.id_token) params.append('id_token_hint', tokens.id_token)
288
+ params.append('post_logout_redirect_uri', cfg.postLogoutRedirectUri)
289
+ api.removeUser()
290
+ globalThis.location.assign(`${cfg.endpoint}/oauth/logout?${params}`)
291
+ },
292
+
293
+ /**
294
+ * For `createClient({ tokenProvider })`. A 401 clears → starts a renewal,
295
+ * and the retried request waits for it.
296
+ */
297
+ tokenProvider() {
298
+ return {
299
+ async getToken() {
300
+ if (renewal) await renewal.catch(() => {})
301
+ return api.token
302
+ },
303
+ clear() {
304
+ api.renew().catch(() => {})
305
+ },
306
+ }
307
+ },
308
+ }
309
+ return api
310
+ }
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
@@ -294,11 +301,19 @@ export interface Schema {
294
301
  *
295
302
  * Client-side (no server round trip):
296
303
  * NO_TOKEN — no token source configured; see ClientConfig.
304
+ * NO_LOGIN_STORE — createClient({ loginStore }) was never given one.
305
+ * LOGIN_REQUIRES_CONFIDENTIAL_CLIENT — login needs clientId + clientSecret.
306
+ * LOGIN_STATE_UNKNOWN, LOGIN_NONCE_MISMATCH, LOGIN_EXCHANGE_FAILED — see client.login.
297
307
  */
298
308
  export type SynthigyErrorCode =
299
309
  | 'UNAUTHORIZED' | 'CLIENT_NOT_FOUND' | 'CLIENT_INACTIVE'
300
310
  | 'PUBLIC_CLIENT_FORBIDDEN' | 'NOT_TRUSTED' | 'USER_NOT_FOUND'
301
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'
315
+ | 'NO_LOGIN_STORE' | 'LOGIN_REQUIRES_CONFIDENTIAL_CLIENT'
316
+ | 'LOGIN_STATE_UNKNOWN' | 'LOGIN_NONCE_MISMATCH' | 'LOGIN_EXCHANGE_FAILED'
302
317
  | 'INVALID_BODY' | 'NO_OPERATIONS' | 'BAD_OP' | 'MISSING_ENTITY'
303
318
  | 'BAD_ARGS_SHAPE'
304
319
  | 'UNKNOWN_OP' | 'UNKNOWN_ENTITY' | 'UNKNOWN_ATTRIBUTE' | 'UNKNOWN_OPERATOR'
@@ -321,7 +336,8 @@ export class SynthigyError extends Error {
321
336
  // ============================================================================
322
337
 
323
338
  export interface ClientConfig {
324
- endpoint: string
339
+ /** Synthigy base URL. Defaults to `$SYNTHIGY_ENDPOINT` (set by `synthigy exec`). */
340
+ endpoint?: string
325
341
  /** OAuth client ID — required with clientSecret for client credentials */
326
342
  clientId?: string
327
343
  clientSecret?: string
@@ -332,6 +348,12 @@ export interface ClientConfig {
332
348
  * with no source at all it throws SynthigyError{code: 'NO_TOKEN'}.
333
349
  */
334
350
  token?: string
351
+ /**
352
+ * Caller-owned token source, checked before every other option. `clear()`
353
+ * is called on a 401 before the single retry; `createBrowserLogin()`'s
354
+ * `tokenProvider()` renews there.
355
+ */
356
+ tokenProvider?: TokenProvider
335
357
  scope?: string
336
358
  /**
337
359
  * Default client_credentials audience, applied to every mint this client
@@ -368,6 +390,131 @@ export interface ClientConfig {
368
390
  * Pass `undici.fetch` with a shared `Agent` for pooled connections in Node.
369
391
  */
370
392
  fetch?: typeof globalThis.fetch
393
+ /**
394
+ * Where in-flight logins live between `login.start()` and the callback.
395
+ * Required to use `client.login` at all — without one, every call throws
396
+ * `NO_LOGIN_STORE` rather than silently keeping state in one process.
397
+ * `MemoryLoginStore` is the single-process dev option.
398
+ */
399
+ loginStore?: LoginStore
400
+ }
401
+
402
+ // ============================================================================
403
+ // Login — OIDC authorization-code + PKCE
404
+ // ============================================================================
405
+
406
+ /** One login in flight: everything the callback needs to finish it. */
407
+ export interface PendingLogin {
408
+ codeVerifier: string
409
+ nonce: string
410
+ returnTo: string
411
+ createdAt: number
412
+ }
413
+
414
+ /**
415
+ * The one piece of protocol state the SDK cannot hold for you.
416
+ *
417
+ * `take` is one-shot: it must return the login AND remove it, so a replayed
418
+ * callback finds nothing. Both methods may be sync or async.
419
+ */
420
+ export interface TokenProvider {
421
+ getToken(audience?: string): string | null | Promise<string | null>
422
+ clear(): void
423
+ }
424
+
425
+ export interface BrowserLoginConfig {
426
+ endpoint: string
427
+ clientId: string
428
+ /** The page that finishes the login (calls `complete()`), registered on the client. */
429
+ redirectUri: string
430
+ /** Page the renewal iframe lands on; default `redirectUri`. It must call `silentCallback()` first. */
431
+ silentRedirectUri?: string
432
+ postLogoutRedirectUri?: string
433
+ /** Default 'openid profile'. */
434
+ scope?: string
435
+ audience?: string
436
+ /** Renew silently shortly before expiry (default true). */
437
+ automaticSilentRenew?: boolean
438
+ /** Merge /oauth/userinfo into `user` after each login and renewal (default false) — the profile (name, email) lives there. */
439
+ loadUserInfo?: boolean
440
+ /** Seconds before expiry that renewal starts (default 60). */
441
+ expiringSeconds?: number
442
+ silentTimeoutMs?: number
443
+ fetch?: typeof globalThis.fetch
444
+ /** Where the in-flight login waits for the callback (default sessionStorage). */
445
+ storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
446
+ }
447
+
448
+ export type BrowserLoginEvent =
449
+ 'userLoaded' | 'userUnloaded' | 'accessTokenExpiring' | 'accessTokenExpired' | 'silentRenewError'
450
+
451
+ export interface BrowserLogin {
452
+ /** id_token claims of the logged-in user (`sub`, `xid`, …), or null. */
453
+ readonly user: Record<string, unknown> | null
454
+ readonly token: string | null
455
+ on(event: BrowserLoginEvent, listener: (payload?: unknown) => void): () => void
456
+ isCallback(): boolean
457
+ /** Redirect to the login page; `prompt: 'none'` returns at once when a session is alive. */
458
+ start(opts?: { prompt?: 'none' | 'login'; returnTo?: string }): Promise<void>
459
+ /** Finish the login the current URL returns from, or null when it carries none. */
460
+ complete(): Promise<{ user: Record<string, unknown>; returnTo: string } | null>
461
+ /** The user cancelled on the IdP: drop the pending login. */
462
+ cancel(): { returnTo: string } | null
463
+ renew(): Promise<Record<string, unknown>>
464
+ removeUser(): void
465
+ logout(): void
466
+ tokenProvider(): TokenProvider
467
+ }
468
+
469
+ /** Browser login as a public client: PKCE, silent renew, tokens in memory. */
470
+ export function createBrowserLogin(config: BrowserLoginConfig): BrowserLogin
471
+
472
+ /** Call first on the silent-redirect page; true when this window was a renewal iframe and answered it. */
473
+ export function silentCallback(): boolean
474
+
475
+ export interface LoginStore {
476
+ put(state: string, login: PendingLogin): void | Promise<void>
477
+ take(state: string): PendingLogin | null | Promise<PendingLogin | null>
478
+ }
479
+
480
+ /** In-memory `LoginStore`. Single process only — see the class docstring. */
481
+ export class MemoryLoginStore implements LoginStore {
482
+ constructor(opts?: { ttlMs?: number })
483
+ put(state: string, login: PendingLogin): void
484
+ take(state: string): PendingLogin | null
485
+ }
486
+
487
+ export interface LoginStartOpts {
488
+ /** URL on YOUR server the IdP redirects back to; must be registered on the client. */
489
+ redirectUri: string
490
+ /** Where to send the visitor once login finishes. Default `'/'`. */
491
+ returnTo?: string
492
+ /** Default `'openid'` — the scope that names the user. */
493
+ scope?: string
494
+ /** Browser-reachable engine URL, when it differs from the server-side one. */
495
+ publicEndpoint?: string
496
+ }
497
+
498
+ export interface LoginCompleteOpts {
499
+ code: string
500
+ state: string
501
+ redirectUri: string
502
+ }
503
+
504
+ export interface LoginTokens {
505
+ access_token: string
506
+ id_token?: string
507
+ refresh_token?: string
508
+ token_type?: string
509
+ scope?: string
510
+ expires_in?: number
511
+ }
512
+
513
+ export interface LoginResult {
514
+ /** Straight out of the id_token — no /data round-trip. */
515
+ user: { xid: string | null; name: string | null; scopes: string[] }
516
+ tokens: LoginTokens
517
+ returnTo: string
371
518
  }
372
519
 
373
520
  // ============================================================================
@@ -566,6 +713,19 @@ export class SynthigyClient {
566
713
  since(opts?: HistorySinceOpts): Promise<HistoryEvent[]>
567
714
  }
568
715
 
716
+ // ── Login — OIDC authorization-code + PKCE ───────────────────────────────
717
+
718
+ login: {
719
+ /** Begin a login; redirect the browser to the returned URL. */
720
+ start(opts: LoginStartOpts): Promise<{ url: string }>
721
+
722
+ /** Finish a login from the callback's `code` + `state`. */
723
+ complete(opts: LoginCompleteOpts): Promise<LoginResult>
724
+
725
+ /** Drop an in-flight login and recover its `returnTo`; null when unknown. */
726
+ cancel(state: string | null | undefined): Promise<{ returnTo: string } | null>
727
+ }
728
+
569
729
  /** Fetch the IAM-filtered model schema via `GET /schema`. Pass entity names to filter. */
570
730
  schema(entities?: string[]): Promise<Schema>
571
731
 
@@ -578,6 +738,17 @@ export class SynthigyClient {
578
738
  op?: 'search' | 'get' | 'slice' | 'purge'
579
739
  }): Promise<LintDiagnostic[]>
580
740
 
741
+ /**
742
+ * Compile an XSQL source to the wire operation the engine would execute
743
+ * (`POST /compile`), without executing it. `params` bind exactly as on
744
+ * `query`, so the result IS what the engine receives — edit it and send
745
+ * it through `exec`.
746
+ */
747
+ compile(source: string, opts?: {
748
+ params?: Record<string, unknown>
749
+ op?: 'search' | 'get' | 'slice' | 'purge'
750
+ }): Promise<Operation>
751
+
581
752
  /**
582
753
  * Mint a one-time account-claim link via `POST /oauth/onboard`.
583
754
  * Confidential client whose principal administers the account — RBAC
@@ -1027,14 +1198,16 @@ export function rel(selections: Selection, args?: Args, alias?: string): Relatio
1027
1198
  export const op: {
1028
1199
  search(entity: string, args: Args | undefined, selection: Selection): Operation
1029
1200
  get(entity: string, args: Record<string, unknown>, selection: Selection): Operation
1030
- sync(entity: string, data: Record<string, unknown>, returning?: boolean): Operation
1031
- stack(entity: string, data: Record<string, unknown>, returning?: boolean): Operation
1201
+ sync(entity: string, data: Record<string, unknown> | Record<string, unknown>[], returning?: boolean): Operation
1202
+ stack(entity: string, data: Record<string, unknown> | Record<string, unknown>[], returning?: boolean): Operation
1032
1203
  slice(entity: string, args: Record<string, unknown>, selection?: Selection): Operation
1033
1204
  delete(entity: string, data: Record<string, unknown>): Operation
1034
1205
  purge(entity: string, args: Args | undefined, selection?: Selection): Operation
1035
1206
  searchTree(entity: string, on: string, args: Args | undefined, selection: Selection): Operation
1036
1207
  getTree(entity: string, root: string, on: string, selection: Selection): Operation
1037
1208
  sqlTemplate(template: string, params?: Record<string, unknown> | unknown[], opts?: { cached?: boolean }): Operation
1209
+ /** An XSQL read, for `exec` batches; `verb` names the op a bare body runs as (default `search`). */
1210
+ query(xsql: string, params?: Record<string, unknown>, verb?: string): Operation
1038
1211
  deployedModel(): Operation
1039
1212
  runtimeModel(): Operation
1040
1213
  deploy(exportContents: string): Operation
@@ -1074,7 +1247,7 @@ export function composeForest<T extends Record<string, unknown>>(
1074
1247
  // Factory
1075
1248
  // ============================================================================
1076
1249
 
1077
- export function createClient(config: ClientConfig): SynthigyClient
1250
+ export function createClient(config?: ClientConfig): SynthigyClient
1078
1251
 
1079
1252
  // ============================================================================
1080
1253
  // Single-client layer — connect once, call the bare verbs, no client threading.
@@ -1083,7 +1256,7 @@ export function createClient(config: ClientConfig): SynthigyClient
1083
1256
  // ============================================================================
1084
1257
 
1085
1258
  /** Install a module-wide default client (destroys any previous one). */
1086
- export function connect(config: ClientConfig): SynthigyClient
1259
+ export function connect(config?: ClientConfig): SynthigyClient
1087
1260
  /** Destroy and uninstall the default client. No-op when not connected. */
1088
1261
  export function disconnect(): void
1089
1262
  /** The default client installed by `connect`, or null. */
@@ -1112,12 +1285,16 @@ export { _delete as delete }
1112
1285
 
1113
1286
  export const schema: SynthigyClient['schema']
1114
1287
  export const lint: SynthigyClient['lint']
1288
+ export const compile: SynthigyClient['compile']
1289
+ export const onboard: SynthigyClient['onboard']
1290
+ export const onboardComplete: SynthigyClient['onboardComplete']
1115
1291
  export const deploy: SynthigyClient['deploy']
1116
1292
  export const destroy: SynthigyClient['destroy']
1117
1293
  export const deployedModel: SynthigyClient['deployedModel']
1118
1294
  export const runtimeModel: SynthigyClient['runtimeModel']
1119
1295
  export const token: SynthigyClient['token']
1120
1296
  export function history(): SynthigyClient['history']
1297
+ export function login(): SynthigyClient['login']
1121
1298
 
1122
1299
  export const listen: SynthigyClient['listen']
1123
1300
  export const observe: SynthigyClient['observe']