@synthigy/sdk 0.1.2 → 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/src/index.js CHANGED
@@ -5,8 +5,10 @@
5
5
  * Handles service-identity token acquisition (client credentials) and
6
6
  * user impersonation via `acting_as`.
7
7
  *
8
+ * `client.login` covers the OAuth2 authorization-code + PKCE exchange for a
9
+ * server-side (confidential) app; sessions and routing stay yours.
10
+ *
8
11
  * Intentionally out of scope — leave to the user's chosen libraries:
9
- * - OAuth2 authorization-code redirect (framework concern)
10
12
  * - ID-token JWT verification (pick `jose`, `jsonwebtoken`, etc.)
11
13
  * - HTTP pooling / retry / backoff (wrap `fetch` as you see fit)
12
14
  *
@@ -34,8 +36,18 @@
34
36
 
35
37
  import { watch as _watch, watchSchema as _watchSchema, getMultiplexer, Watch, SchemaWatch, WatchError } from './watch.js'
36
38
  import { watchQuery as _watchQuery, watchSqlTemplate as _watchSqlTemplate, QueryWatch, SqlTemplateWatch } from './watch-query.js'
39
+ import { createLoginApi, rejectingLoginStore, MemoryLoginStore } from './login.js'
40
+ import { createBrowserLogin as _createBrowserLogin, silentCallback } from './browser-login.js'
41
+
42
+ export { Watch, SchemaWatch, WatchError, QueryWatch, SqlTemplateWatch, MemoryLoginStore, silentCallback }
37
43
 
38
- export { Watch, SchemaWatch, WatchError, QueryWatch, SqlTemplateWatch }
44
+ /**
45
+ * Browser login as a public client (PKCE, silent renew, tokens in memory).
46
+ * Pass `login.tokenProvider()` to `createClient({ tokenProvider })`.
47
+ */
48
+ export function createBrowserLogin(config) {
49
+ return _createBrowserLogin(config, { SynthigyError })
50
+ }
39
51
 
40
52
  // ============================================================================
41
53
  // Operators — helper functions for building where clauses
@@ -423,8 +435,7 @@ class TokenManager {
423
435
  }
424
436
 
425
437
  // ============================================================================
426
- // Supervised stdio — SYNTHIGY_SUPERVISED=1, see
427
- // docs/plans/PLAN-EXEC-IDENTITY.md steps 2-4. The SDK never mints locally
438
+ // Supervised stdio — SYNTHIGY_SUPERVISED=1. The SDK never mints locally
428
439
  // under this mode: the CLI/commander is the platform's stdio owner, so a
429
440
  // token is asked for over JSON-RPC on the process's OWN stdio (supervise
430
441
  // grammar) instead. {token, expires_in} is deliberately byte-compatible
@@ -520,6 +531,7 @@ class SupervisedIO {
520
531
  _ask(method, params, timeoutMs) {
521
532
  this._ensureReader()
522
533
  this._ensureWriteGuard()
534
+ process.stdin.ref?.()
523
535
  const id = ++this._nextId
524
536
  return new Promise((resolve) => {
525
537
  let settled = false
@@ -529,6 +541,8 @@ class SupervisedIO {
529
541
  settled = true
530
542
  clearTimeout(timer)
531
543
  this._pending.delete(id)
544
+ // a ref'd stdin keeps the event loop alive — the process would never exit
545
+ if (this._pending.size === 0) process.stdin.unref?.()
532
546
  resolve(v)
533
547
  }
534
548
  timer = setTimeout(() => deliver(null), timeoutMs)
@@ -592,8 +606,7 @@ function _getSupervisedIO() {
592
606
  /**
593
607
  * Token source for SYNTHIGY_SUPERVISED=1 — asks `auth.token` over the
594
608
  * process's own stdio instead of minting locally (the CLI/commander is
595
- * the platform's stdio owner; this SDK never mints, per
596
- * PLAN-EXEC-IDENTITY step 2). Same public shape as TokenManager
609
+ * the platform's stdio owner; this SDK never mints). Same public shape as TokenManager
597
610
  * (getToken/clear) so call sites don't care which source is installed —
598
611
  * a thin handle onto the real, process-wide SupervisedIO singleton
599
612
  * (cache and all), so multiple clients in one process share one cache
@@ -609,7 +622,7 @@ class SupervisedTokenSource {
609
622
  }
610
623
  }
611
624
 
612
- /** The teaching throw — PLAN-EXEC-IDENTITY step 3: the error IS the UX, no flag, no silent anonymous fallback. */
625
+ /** The teaching throw: the error IS the UX, no flag, no silent anonymous fallback. */
613
626
  function _noTokenError() {
614
627
  return new SynthigyError(
615
628
  'no Synthigy token: set token, or clientId + clientSecret, or set ' +
@@ -647,6 +660,16 @@ const ERROR_CATEGORIES = {
647
660
  PROVISION_FORBIDDEN: 'auth',
648
661
  CLAIM_INVALID: 'auth',
649
662
  NO_TOKEN: 'auth', // client-side: no token source configured — retrying can't fix it
663
+ NO_ENDPOINT: 'validation', // client-side: no endpoint configured — retrying can't fix it
664
+ NO_LOGIN_STORE: 'auth', // client-side: no login store registered — retrying can't fix it
665
+ LOGIN_NONCE_MISMATCH: 'auth',
666
+ LOGIN_EXCHANGE_FAILED: 'auth',
667
+ SILENT_TIMEOUT: 'auth',
668
+ // OAuth error codes the IdP redirects back with (RFC 6749 4.1.2.1, OIDC 3.1.2.6)
669
+ access_denied: 'auth',
670
+ login_required: 'auth',
671
+ consent_required: 'auth',
672
+ interaction_required: 'auth',
650
673
  // iam
651
674
  FORBIDDEN: 'iam',
652
675
  FORBIDDEN_OP: 'iam',
@@ -681,7 +704,13 @@ const ERROR_CATEGORIES = {
681
704
  PARAM_MISSING: 'validation',
682
705
  PARAM_TYPE_MISMATCH: 'validation',
683
706
  NOT_CONNECTED: 'validation', // client-side guard — retrying without connecting can't fix it
707
+ LOGIN_STATE_UNKNOWN: 'validation',
708
+ LOGIN_REQUIRES_CONFIDENTIAL_CLIENT: 'validation',
684
709
  XID_REQUIRED: 'validation',
710
+ NAMESPACE_REQUIRED: 'validation',
711
+ DUPLICATE_OPERATION: 'validation',
712
+ BATCH_MEMBER_UNRESOLVED: 'validation',
713
+ CONFIG_ERROR: 'validation',
685
714
  CLAIM_METHOD_NOT_ALLOWED: 'validation',
686
715
  PASSWORD_TOO_WEAK: 'validation',
687
716
  RETURN_URL_NOT_REGISTERED: 'validation',
@@ -831,15 +860,17 @@ async function onboardError(resp) {
831
860
  class SynthigyClient {
832
861
  /**
833
862
  * @param {Object} config
834
- * @param {string} config.endpoint - Synthigy base URL
863
+ * @param {string} [config.endpoint] - Synthigy base URL (default $SYNTHIGY_ENDPOINT)
835
864
  * @param {string} [config.clientId] - OAuth client ID (for client credentials)
836
865
  * @param {string} [config.clientSecret] - OAuth client secret
837
866
  * @param {string} [config.token] - Static bearer token (alternative to client credentials)
867
+ * @param {{getToken: (audience?: string) => string|null|Promise<string|null>, clear: () => void}} [config.tokenProvider]
868
+ * - Caller-owned token source; `clear()` is called on a 401 before the one retry
838
869
  *
839
870
  * On Node, with none of the above, the constructor also falls back to —
840
871
  * when SYNTHIGY_SUPERVISED=1 — a supervised-stdio auth.token ask (the
841
872
  * pipe beats the env var: it can refresh mid-run), then the
842
- * SYNTHIGY_TOKEN env var (see docs/plans/PLAN-EXEC-IDENTITY.md step 3).
873
+ * SYNTHIGY_TOKEN env var.
843
874
  * With no source at all it throws a SynthigyError whose code is
844
875
  * NO_TOKEN and whose message teaches the fix. A browser has neither
845
876
  * env vars nor a stdio parent, so it always requires an explicit source.
@@ -863,17 +894,24 @@ class SynthigyClient {
863
894
  * fetch: (url, init) => fetch(url, { ...init, dispatcher: agent }),
864
895
  * })
865
896
  */
866
- constructor(config) {
867
- this._endpoint = config.endpoint.replace(/\/$/, '')
897
+ constructor(config = {}) {
898
+ const endpoint = config.endpoint
899
+ ?? (typeof process !== 'undefined' ? process.env?.SYNTHIGY_ENDPOINT : undefined)
900
+ if (!endpoint) {
901
+ throw new SynthigyError(
902
+ 'no endpoint — pass config.endpoint, or set SYNTHIGY_ENDPOINT (run under `synthigy exec`)',
903
+ 'NO_ENDPOINT')
904
+ }
905
+ this._endpoint = endpoint.replace(/\/$/, '')
868
906
  this._dataUrl = `${this._endpoint}/data`
869
907
  this._defaultActingAs = config.actingAs ?? null
870
908
  this._defaultKeyFormat = config.keyFormat ?? null
871
909
  this._timeout = config.timeout ?? null
872
910
  this._fetch = config.fetch ?? globalThis.fetch.bind(globalThis)
873
- // Stored so the OIDC helpers can authenticate code-exchange and
874
- // default the ID-token audience to this client's own id.
911
+ // Stored so `client.login` can authenticate the code exchange.
875
912
  this._clientId = config.clientId ?? null
876
913
  this._clientSecret = config.clientSecret ?? null
914
+ this._loginStore = config.loginStore ?? rejectingLoginStore(SynthigyError)
877
915
  // Long-lived contexts (BFFs, Node services) want the upstream SSE pinned
878
916
  // open across user-watch churn so the plug session keyed by
879
917
  // [sub, client_id] isn't torn down between watches. Replaces the
@@ -890,7 +928,12 @@ class SynthigyClient {
890
928
  ?? (typeof process !== 'undefined' ? process.env?.SYNTHIGY_AUDIENCE : undefined)
891
929
  ?? PLATFORM_AUDIENCE
892
930
 
893
- if (config.token !== undefined && config.token !== null) {
931
+ if (config.tokenProvider) {
932
+ // Caller-owned source ({ getToken(audience), clear() }) — e.g. the
933
+ // browser login. clear() on a 401 is its cue to refresh.
934
+ this._tokenManager = config.tokenProvider
935
+ this._getToken = () => this._tokenManager.getToken(this._defaultAudience)
936
+ } else if (config.token !== undefined && config.token !== null) {
894
937
  // Static token mode — empty string allowed for unauth / dev servers.
895
938
  this._tokenManager = null
896
939
  this._getToken = async () => config.token
@@ -1774,6 +1817,55 @@ class SynthigyClient {
1774
1817
  return json.diagnostics ?? []
1775
1818
  }
1776
1819
 
1820
+ /**
1821
+ * Compile an XSQL source to the wire operation the engine would execute,
1822
+ * via `POST /compile` — nothing runs. The compiler is the authority on
1823
+ * the wire format, so this is how you take programmatic control of a
1824
+ * query instead of hand-writing the JSON: edit the returned map and send
1825
+ * it through `batch()`.
1826
+ *
1827
+ * `params` bind exactly as they do on `query()`, so what comes back IS
1828
+ * what the engine receives.
1829
+ *
1830
+ * @param {string} source - XSQL source (bare body or full `@verb` document)
1831
+ * @param {Object} [opts]
1832
+ * @param {Object} [opts.params] - `?name:type` bindings
1833
+ * @param {'search'|'get'|'slice'|'purge'} [opts.op='search'] - verb for a bare body
1834
+ * @returns {Promise<Object>} the wire op `{op, entity, args, selections}`
1835
+ *
1836
+ * @example
1837
+ * ```js
1838
+ * const op = await client.compile('movie (limit ?n:int=5)\n title\n',
1839
+ * { params: { n: 2 } })
1840
+ * op.args._limit = 10
1841
+ * const [rows] = await client.batch([op])
1842
+ * ```
1843
+ */
1844
+ async compile(source, opts) {
1845
+ const op = { op: 'xsql', xsql: xsqlDocument(source, opts?.op ?? 'search') }
1846
+ if (opts?.params) op.params = opts.params
1847
+ const resp = await this._fetchAuth(`${this._endpoint}/compile`, {
1848
+ method: 'POST',
1849
+ headers: { 'Content-Type': 'application/json' },
1850
+ body: JSON.stringify({ operations: [op] }),
1851
+ })
1852
+ const json = await resp.json().catch(() => ({}))
1853
+ if (!resp.ok) {
1854
+ throw new SynthigyError(
1855
+ json.error?.message || `Compile request failed (${resp.status})`,
1856
+ json.error?.code || 'HTTP_ERROR'
1857
+ )
1858
+ }
1859
+ const [result] = json.results ?? []
1860
+ if (!result?.ok) {
1861
+ throw new SynthigyError(
1862
+ result?.error?.message || 'Compile failed',
1863
+ result?.error?.code || 'XSQL_PARSE_ERROR'
1864
+ )
1865
+ }
1866
+ return result.operation
1867
+ }
1868
+
1777
1869
  /**
1778
1870
  * Mint a one-time account-claim link via `POST /oauth/onboard`.
1779
1871
  * Confidential client whose principal administers the account — RBAC
@@ -1961,6 +2053,11 @@ class SynthigyClient {
1961
2053
  return body.result
1962
2054
  }
1963
2055
 
2056
+ get login() {
2057
+ if (!this._login) this._login = createLoginApi(this, { SynthigyError })
2058
+ return this._login
2059
+ }
2060
+
1964
2061
  // Defined as a regular property so the .d.ts namespace `client.history.foo`
1965
2062
  // shape works at edit time.
1966
2063
  get history() {
@@ -2426,6 +2523,7 @@ export const op = {
2426
2523
  searchTree: (entity, on, args, sel) => ({ op: 'search-tree', entity, on, args, selections: normalizeSelection(sel) }),
2427
2524
  getTree: (entity, root, on, sel) => ({ op: 'get-tree', entity, root, on, selections: normalizeSelection(sel) }),
2428
2525
  sqlTemplate: (template, params) => ({ op: 'sql-template', template, params: params || [] }),
2526
+ query: (xsql, params, verb = 'search') => ({ op: 'xsql', xsql: xsqlDocument(xsql, verb), ...(params !== undefined && { params }) }),
2429
2527
  deployedModel: () => ({ op: 'deployed-model' }),
2430
2528
  runtimeModel: () => ({ op: 'runtime-model' }),
2431
2529
  deploy: (exportContents) => ({ op: 'deploy', data: exportContents }),
@@ -2440,7 +2538,7 @@ export const op = {
2440
2538
  * Create a Synthigy client.
2441
2539
  *
2442
2540
  * @param {Object} config
2443
- * @param {string} config.endpoint - Synthigy base URL
2541
+ * @param {string} [config.endpoint] - Synthigy base URL (default $SYNTHIGY_ENDPOINT)
2444
2542
  * @param {string} [config.clientId] - OAuth client ID
2445
2543
  * @param {string} [config.clientSecret] - OAuth client secret
2446
2544
  * @param {string} [config.token] - Static bearer token (alternative to client credentials)
@@ -2467,7 +2565,7 @@ export const op = {
2467
2565
  * })
2468
2566
  * ```
2469
2567
  */
2470
- export function createClient(config) {
2568
+ export function createClient(config = {}) {
2471
2569
  return new SynthigyClient(config)
2472
2570
  }
2473
2571
 
@@ -2496,7 +2594,7 @@ let _defaultClient = null
2496
2594
  * @param {Object} config - same shape as {@link createClient}
2497
2595
  * @returns {SynthigyClient} the installed client (for introspection)
2498
2596
  */
2499
- export function connect(config) {
2597
+ export function connect(config = {}) {
2500
2598
  const prev = _defaultClient
2501
2599
  _defaultClient = new SynthigyClient(config)
2502
2600
  if (prev) prev.close() // destroy the old one, server-restart style
@@ -2553,6 +2651,7 @@ export { _delete as delete }
2553
2651
  // Schema / introspection
2554
2652
  export const schema = (...a) => _dflt().schema(...a)
2555
2653
  export const lint = (...a) => _dflt().lint(...a)
2654
+ export const compile = (...a) => _dflt().compile(...a)
2556
2655
  export const onboard = (...a) => _dflt().onboard(...a)
2557
2656
  export const onboardComplete = (...a) => _dflt().onboardComplete(...a)
2558
2657
  export const deploy = (...a) => _dflt().deploy(...a)
@@ -2561,6 +2660,7 @@ export const deployedModel = (...a) => _dflt().deployedModel(...a)
2561
2660
  export const runtimeModel = (...a) => _dflt().runtimeModel(...a)
2562
2661
  export const token = (...a) => _dflt().token(...a)
2563
2662
  export const history = () => _dflt().history
2663
+ export const login = () => _dflt().login
2564
2664
 
2565
2665
  // Streaming + live watches
2566
2666
  export const listen = (...a) => _dflt().listen(...a)
package/src/login.js ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * OIDC authorization-code + PKCE — the protocol half of a browser login.
3
+ *
4
+ * What this owns: the PKCE pair, the authorize URL, the code exchange, and
5
+ * reading the subject out of the id_token. What it deliberately does NOT own:
6
+ * sessions, cookies, redirects, routing. Those are your app's, and they are
7
+ * where a framework dependency would come from. The one piece of state the
8
+ * protocol itself needs — the in-flight login, from /login until the callback
9
+ * comes back — you supply as a `loginStore`.
10
+ *
11
+ * Zero dependencies and no Node built-ins: WebCrypto, btoa/atob, URL.
12
+ */
13
+
14
+ const PKCE_VERIFIER_BYTES = 32
15
+ const STATE_BYTES = 24
16
+ const DEFAULT_TTL_MS = 5 * 60_000
17
+
18
+ function base64urlEncode(bytes) {
19
+ let binary = ''
20
+ for (const b of bytes) binary += String.fromCharCode(b)
21
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
22
+ }
23
+
24
+ function base64urlDecode(text) {
25
+ const padded = text.replace(/-/g, '+').replace(/_/g, '/')
26
+ const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4))
27
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0))
28
+ return new TextDecoder().decode(bytes)
29
+ }
30
+
31
+ function randomToken(bytes) {
32
+ return base64urlEncode(globalThis.crypto.getRandomValues(new Uint8Array(bytes)))
33
+ }
34
+
35
+ /** A fresh PKCE verifier and its S256 challenge (RFC 7636). */
36
+ export async function pkce() {
37
+ const codeVerifier = randomToken(PKCE_VERIFIER_BYTES)
38
+ const digest = await globalThis.crypto.subtle.digest(
39
+ 'SHA-256', new TextEncoder().encode(codeVerifier))
40
+ return { codeVerifier, codeChallenge: base64urlEncode(new Uint8Array(digest)) }
41
+ }
42
+
43
+ /**
44
+ * Read a JWT's payload without verifying its signature.
45
+ *
46
+ * Safe here and nowhere else: this token arrived in the body of our own
47
+ * client-authenticated POST to the token endpoint, over TLS, so its origin is
48
+ * already established by the channel. A signature check would be guarding
49
+ * against an attacker who can already answer as the IdP. Never use this on a
50
+ * token that reached you any other way — verifying those stays out of the
51
+ * SDK's scope, and `jose` is the library for it.
52
+ */
53
+ export function decodeJwtPayload(jwt) {
54
+ const parts = String(jwt ?? '').split('.')
55
+ if (parts.length < 2) return null
56
+ try {
57
+ return JSON.parse(base64urlDecode(parts[1]))
58
+ } catch {
59
+ return null
60
+ }
61
+ }
62
+
63
+ /**
64
+ * In-flight logins in a Map — SINGLE PROCESS ONLY.
65
+ *
66
+ * Every instance behind a load balancer gets its own Map, so a callback that
67
+ * lands on a different instance than the one that started the login fails with
68
+ * LOGIN_STATE_UNKNOWN. Real deployments back the store with whatever already
69
+ * holds their sessions (Redis, Postgres, a signed cookie).
70
+ */
71
+ export class MemoryLoginStore {
72
+ constructor({ ttlMs = DEFAULT_TTL_MS } = {}) {
73
+ this._ttlMs = ttlMs
74
+ this._pending = new Map()
75
+ }
76
+
77
+ put(state, login) {
78
+ // Swept on write rather than on a timer: a timer would keep a Node
79
+ // process alive and would have to be torn down by someone.
80
+ const cutoff = Date.now() - this._ttlMs
81
+ for (const [key, value] of this._pending) {
82
+ if (value.createdAt <= cutoff) this._pending.delete(key)
83
+ }
84
+ this._pending.set(state, login)
85
+ }
86
+
87
+ take(state) {
88
+ const login = this._pending.get(state)
89
+ if (!login) return null
90
+ this._pending.delete(state)
91
+ return login.createdAt <= Date.now() - this._ttlMs ? null : login
92
+ }
93
+ }
94
+
95
+ export function noLoginStoreError(SynthigyError) {
96
+ return new SynthigyError(
97
+ 'no login store: pass createClient({ loginStore }) — an object with ' +
98
+ 'put(state, login) and take(state), backed by whatever already holds ' +
99
+ 'your sessions. For a single-process dev server, import ' +
100
+ '{ MemoryLoginStore } and pass new MemoryLoginStore().',
101
+ 'NO_LOGIN_STORE')
102
+ }
103
+
104
+ /** A store that refuses rather than silently keeping logins in one process. */
105
+ export function rejectingLoginStore(SynthigyError) {
106
+ return {
107
+ put() { throw noLoginStoreError(SynthigyError) },
108
+ take() { throw noLoginStoreError(SynthigyError) },
109
+ }
110
+ }
111
+
112
+ /**
113
+ * The `client.login` namespace: start / complete / cancel.
114
+ *
115
+ * Takes the client rather than importing it, so this module never depends on
116
+ * index.js and the two can't cycle.
117
+ */
118
+ export function createLoginApi(client, { SynthigyError }) {
119
+ const requireConfidential = () => {
120
+ if (!client._clientId || !client._clientSecret) {
121
+ throw new SynthigyError(
122
+ 'login needs clientId + clientSecret: the code exchange authenticates ' +
123
+ 'this process as a confidential client. A browser or native app is a ' +
124
+ 'public client and drives the same flow with a PKCE library of its ' +
125
+ 'own — see docs/AUTH.md.',
126
+ 'LOGIN_REQUIRES_CONFIDENTIAL_CLIENT')
127
+ }
128
+ }
129
+
130
+ return {
131
+ /**
132
+ * Begin a login: returns the URL to redirect the browser to, and stashes
133
+ * the PKCE verifier under a fresh `state` in the login store.
134
+ */
135
+ async start({ redirectUri, returnTo = '/', scope = 'openid', publicEndpoint } = {}) {
136
+ requireConfidential()
137
+ if (!redirectUri) {
138
+ throw new SynthigyError(
139
+ 'login.start needs redirectUri — the URL on YOUR server the IdP ' +
140
+ 'sends the browser back to, registered on this OAuth client.',
141
+ 'LOGIN_REQUIRES_CONFIDENTIAL_CLIENT')
142
+ }
143
+ const state = randomToken(STATE_BYTES)
144
+ const nonce = randomToken(STATE_BYTES)
145
+ const { codeVerifier, codeChallenge } = await pkce()
146
+ // Stored BEFORE the URL is handed out: a browser that follows the
147
+ // redirect faster than the store commits would come back to nothing.
148
+ await client._loginStore.put(state, { codeVerifier, nonce, returnTo, createdAt: Date.now() })
149
+
150
+ const url = new URL(`${publicEndpoint ?? client._endpoint}/oauth/authorize`)
151
+ url.searchParams.set('response_type', 'code')
152
+ url.searchParams.set('client_id', client._clientId)
153
+ url.searchParams.set('redirect_uri', redirectUri)
154
+ url.searchParams.set('scope', scope)
155
+ url.searchParams.set('state', state)
156
+ url.searchParams.set('code_challenge', codeChallenge)
157
+ url.searchParams.set('code_challenge_method', 'S256')
158
+ url.searchParams.set('nonce', nonce)
159
+ return { url: url.toString() }
160
+ },
161
+
162
+ /**
163
+ * Exchange the callback's code for tokens and the user behind them.
164
+ *
165
+ * `user.xid` comes straight out of the id_token — the engine puts it on
166
+ * every token it signs — so this costs no /data round-trip.
167
+ */
168
+ async complete({ code, state, redirectUri } = {}) {
169
+ requireConfidential()
170
+ const pending = state ? await client._loginStore.take(state) : null
171
+ if (!pending) {
172
+ throw new SynthigyError(
173
+ 'unknown or expired login state: the store never saw it, it was ' +
174
+ 'already consumed, or it timed out. A callback landing on a ' +
175
+ 'different instance than the one that served /login does this.',
176
+ 'LOGIN_STATE_UNKNOWN')
177
+ }
178
+
179
+ const params = new URLSearchParams({
180
+ grant_type: 'authorization_code',
181
+ code,
182
+ redirect_uri: redirectUri,
183
+ client_id: client._clientId,
184
+ client_secret: client._clientSecret,
185
+ code_verifier: pending.codeVerifier,
186
+ })
187
+ const resp = await client._fetch(`${client._endpoint}/oauth/token`, {
188
+ method: 'POST',
189
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
190
+ body: params,
191
+ })
192
+ if (!resp.ok) {
193
+ const text = await resp.text().catch(() => '')
194
+ throw new SynthigyError(
195
+ `authorization-code exchange failed (${resp.status})`,
196
+ 'LOGIN_EXCHANGE_FAILED', text, { status: resp.status })
197
+ }
198
+ const tokens = await resp.json()
199
+ const claims = decodeJwtPayload(tokens.id_token)
200
+ if (!claims) {
201
+ throw new SynthigyError(
202
+ 'token response carried no readable id_token — request the openid ' +
203
+ 'scope, which is what names the user.',
204
+ 'LOGIN_EXCHANGE_FAILED')
205
+ }
206
+ if (claims.nonce !== pending.nonce) {
207
+ throw new SynthigyError(
208
+ 'id_token nonce does not match the one this login started with',
209
+ 'LOGIN_NONCE_MISMATCH')
210
+ }
211
+
212
+ return {
213
+ user: {
214
+ xid: claims.xid ?? null,
215
+ name: claims.sub ?? null,
216
+ // Granted scopes live in the token response; the id_token carries
217
+ // them only when the IdP chose to mirror them.
218
+ scopes: String(tokens.scope ?? claims.scope ?? '').split(' ').filter(Boolean),
219
+ },
220
+ tokens,
221
+ returnTo: pending.returnTo,
222
+ }
223
+ },
224
+
225
+ /**
226
+ * Discard an in-flight login and recover where the visitor was headed.
227
+ *
228
+ * Cancelling on the IdP's login page is a normal outcome (RFC 6749
229
+ * §4.1.2.1 sends `error` back with no code), so an unknown state is null
230
+ * here rather than a throw.
231
+ */
232
+ async cancel(state) {
233
+ const pending = state ? await client._loginStore.take(state) : null
234
+ return pending ? { returnTo: pending.returnTo } : null
235
+ },
236
+ }
237
+ }