@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
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'
|
|
37
41
|
|
|
38
|
-
export { Watch, SchemaWatch, WatchError, QueryWatch, SqlTemplateWatch }
|
|
42
|
+
export { Watch, SchemaWatch, WatchError, QueryWatch, SqlTemplateWatch, MemoryLoginStore, silentCallback }
|
|
43
|
+
|
|
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
|
|
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
|
|
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
|
|
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',
|
|
@@ -733,6 +762,15 @@ const RETRYABLE_CATEGORIES = new Set(['network', 'rate_limit', 'internal'])
|
|
|
733
762
|
* - .requestId— request ID for cross-correlation with server logs
|
|
734
763
|
* - .status — HTTP status code (for transport-level errors)
|
|
735
764
|
*/
|
|
765
|
+
/**
|
|
766
|
+
* The platform API this SDK is a client of. `/data`, `/schema`, `/history`,
|
|
767
|
+
* `/logs` and subscriptions all require a token bound to it. It identifies the
|
|
768
|
+
* API, never a deployment, so it is the same string on localhost and in
|
|
769
|
+
* production — which is exactly why it is a constant here rather than
|
|
770
|
+
* something every caller configures.
|
|
771
|
+
*/
|
|
772
|
+
export const PLATFORM_AUDIENCE = 'https://synthigy.com'
|
|
773
|
+
|
|
736
774
|
export class SynthigyError extends Error {
|
|
737
775
|
constructor(message, code, details, extras) {
|
|
738
776
|
super(message)
|
|
@@ -822,15 +860,17 @@ async function onboardError(resp) {
|
|
|
822
860
|
class SynthigyClient {
|
|
823
861
|
/**
|
|
824
862
|
* @param {Object} config
|
|
825
|
-
* @param {string} config.endpoint - Synthigy base URL
|
|
863
|
+
* @param {string} [config.endpoint] - Synthigy base URL (default $SYNTHIGY_ENDPOINT)
|
|
826
864
|
* @param {string} [config.clientId] - OAuth client ID (for client credentials)
|
|
827
865
|
* @param {string} [config.clientSecret] - OAuth client secret
|
|
828
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
|
|
829
869
|
*
|
|
830
870
|
* On Node, with none of the above, the constructor also falls back to —
|
|
831
871
|
* when SYNTHIGY_SUPERVISED=1 — a supervised-stdio auth.token ask (the
|
|
832
872
|
* pipe beats the env var: it can refresh mid-run), then the
|
|
833
|
-
* SYNTHIGY_TOKEN env var
|
|
873
|
+
* SYNTHIGY_TOKEN env var.
|
|
834
874
|
* With no source at all it throws a SynthigyError whose code is
|
|
835
875
|
* NO_TOKEN and whose message teaches the fix. A browser has neither
|
|
836
876
|
* env vars nor a stdio parent, so it always requires an explicit source.
|
|
@@ -854,30 +894,46 @@ class SynthigyClient {
|
|
|
854
894
|
* fetch: (url, init) => fetch(url, { ...init, dispatcher: agent }),
|
|
855
895
|
* })
|
|
856
896
|
*/
|
|
857
|
-
constructor(config) {
|
|
858
|
-
|
|
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(/\/$/, '')
|
|
859
906
|
this._dataUrl = `${this._endpoint}/data`
|
|
860
907
|
this._defaultActingAs = config.actingAs ?? null
|
|
861
908
|
this._defaultKeyFormat = config.keyFormat ?? null
|
|
862
909
|
this._timeout = config.timeout ?? null
|
|
863
910
|
this._fetch = config.fetch ?? globalThis.fetch.bind(globalThis)
|
|
864
|
-
// Stored so
|
|
865
|
-
// default the ID-token audience to this client's own id.
|
|
911
|
+
// Stored so `client.login` can authenticate the code exchange.
|
|
866
912
|
this._clientId = config.clientId ?? null
|
|
867
913
|
this._clientSecret = config.clientSecret ?? null
|
|
914
|
+
this._loginStore = config.loginStore ?? rejectingLoginStore(SynthigyError)
|
|
868
915
|
// Long-lived contexts (BFFs, Node services) want the upstream SSE pinned
|
|
869
916
|
// open across user-watch churn so the plug session keyed by
|
|
870
917
|
// [sub, client_id] isn't torn down between watches. Replaces the
|
|
871
918
|
// earlier "dummy keepalive watch" workaround.
|
|
872
919
|
this._keepAlive = config.keepAlive === true
|
|
873
|
-
//
|
|
874
|
-
//
|
|
875
|
-
//
|
|
920
|
+
// A client_credentials mint naming no audience resolves to the
|
|
921
|
+
// identity-only OIDC audience, which /data rejects. This SDK is the client
|
|
922
|
+
// for the platform API, so PLATFORM_AUDIENCE is what it asks for — there
|
|
923
|
+
// is nothing for a caller to configure, and nowhere to look the value up
|
|
924
|
+
// if there were: the server does not advertise it in discovery. Minting a
|
|
925
|
+
// token for some OTHER API is a per-call argument on token(), not a
|
|
926
|
+
// property of the client. The env var stays only as an escape hatch.
|
|
876
927
|
this._defaultAudience = config.audience
|
|
877
928
|
?? (typeof process !== 'undefined' ? process.env?.SYNTHIGY_AUDIENCE : undefined)
|
|
878
|
-
??
|
|
929
|
+
?? PLATFORM_AUDIENCE
|
|
879
930
|
|
|
880
|
-
if (config.
|
|
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) {
|
|
881
937
|
// Static token mode — empty string allowed for unauth / dev servers.
|
|
882
938
|
this._tokenManager = null
|
|
883
939
|
this._getToken = async () => config.token
|
|
@@ -1761,6 +1817,55 @@ class SynthigyClient {
|
|
|
1761
1817
|
return json.diagnostics ?? []
|
|
1762
1818
|
}
|
|
1763
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
|
+
|
|
1764
1869
|
/**
|
|
1765
1870
|
* Mint a one-time account-claim link via `POST /oauth/onboard`.
|
|
1766
1871
|
* Confidential client whose principal administers the account — RBAC
|
|
@@ -1863,6 +1968,34 @@ class SynthigyClient {
|
|
|
1863
1968
|
return result.data
|
|
1864
1969
|
}
|
|
1865
1970
|
|
|
1971
|
+
/**
|
|
1972
|
+
* Deploy a dataset version from a modeler export. Pass the export file's
|
|
1973
|
+
* contents verbatim — the server decodes it. Requires `dataset:deploy`.
|
|
1974
|
+
*
|
|
1975
|
+
* @param {string} exportContents - Contents of a `<Dataset>@<version>.json` export
|
|
1976
|
+
* @param {Object} [opts] - Options ({ actingAs })
|
|
1977
|
+
* @returns {Promise<{deployed: boolean, version: string, dataset: string}>}
|
|
1978
|
+
*/
|
|
1979
|
+
async deploy(exportContents, opts) {
|
|
1980
|
+
const [result] = await this.exec([{ op: 'deploy', data: exportContents }], opts)
|
|
1981
|
+
if (!result.ok) throw _errorFromServer(result.error, undefined, result._requestId)
|
|
1982
|
+
return result.data
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
/**
|
|
1986
|
+
* Destroy a dataset: every version, every table, every row. Requires
|
|
1987
|
+
* `dataset:delete`. Idempotent — a dataset that is already gone is not an error.
|
|
1988
|
+
*
|
|
1989
|
+
* @param {string} datasetXid - The dataset xid (`deploy()` returns it)
|
|
1990
|
+
* @param {Object} [opts] - Options ({ actingAs })
|
|
1991
|
+
* @returns {Promise<boolean>}
|
|
1992
|
+
*/
|
|
1993
|
+
async destroy(datasetXid, opts) {
|
|
1994
|
+
const [result] = await this.exec([{ op: 'delete', entity: 'dataset', data: { xid: datasetXid } }], opts)
|
|
1995
|
+
if (!result.ok) throw _errorFromServer(result.error, undefined, result._requestId)
|
|
1996
|
+
return result.data
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1866
1999
|
/**
|
|
1867
2000
|
* Fetch the runtime ERD model — the deployed model augmented with:
|
|
1868
2001
|
* - `xid` / `euuid` identity attrs on every entity
|
|
@@ -1920,6 +2053,11 @@ class SynthigyClient {
|
|
|
1920
2053
|
return body.result
|
|
1921
2054
|
}
|
|
1922
2055
|
|
|
2056
|
+
get login() {
|
|
2057
|
+
if (!this._login) this._login = createLoginApi(this, { SynthigyError })
|
|
2058
|
+
return this._login
|
|
2059
|
+
}
|
|
2060
|
+
|
|
1923
2061
|
// Defined as a regular property so the .d.ts namespace `client.history.foo`
|
|
1924
2062
|
// shape works at edit time.
|
|
1925
2063
|
get history() {
|
|
@@ -2385,8 +2523,11 @@ export const op = {
|
|
|
2385
2523
|
searchTree: (entity, on, args, sel) => ({ op: 'search-tree', entity, on, args, selections: normalizeSelection(sel) }),
|
|
2386
2524
|
getTree: (entity, root, on, sel) => ({ op: 'get-tree', entity, root, on, selections: normalizeSelection(sel) }),
|
|
2387
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 }) }),
|
|
2388
2527
|
deployedModel: () => ({ op: 'deployed-model' }),
|
|
2389
2528
|
runtimeModel: () => ({ op: 'runtime-model' }),
|
|
2529
|
+
deploy: (exportContents) => ({ op: 'deploy', data: exportContents }),
|
|
2530
|
+
destroy: (datasetXid) => ({ op: 'delete', entity: 'dataset', data: { xid: datasetXid } }),
|
|
2390
2531
|
}
|
|
2391
2532
|
|
|
2392
2533
|
// ============================================================================
|
|
@@ -2397,7 +2538,7 @@ export const op = {
|
|
|
2397
2538
|
* Create a Synthigy client.
|
|
2398
2539
|
*
|
|
2399
2540
|
* @param {Object} config
|
|
2400
|
-
* @param {string} config.endpoint - Synthigy base URL
|
|
2541
|
+
* @param {string} [config.endpoint] - Synthigy base URL (default $SYNTHIGY_ENDPOINT)
|
|
2401
2542
|
* @param {string} [config.clientId] - OAuth client ID
|
|
2402
2543
|
* @param {string} [config.clientSecret] - OAuth client secret
|
|
2403
2544
|
* @param {string} [config.token] - Static bearer token (alternative to client credentials)
|
|
@@ -2424,7 +2565,7 @@ export const op = {
|
|
|
2424
2565
|
* })
|
|
2425
2566
|
* ```
|
|
2426
2567
|
*/
|
|
2427
|
-
export function createClient(config) {
|
|
2568
|
+
export function createClient(config = {}) {
|
|
2428
2569
|
return new SynthigyClient(config)
|
|
2429
2570
|
}
|
|
2430
2571
|
|
|
@@ -2453,7 +2594,7 @@ let _defaultClient = null
|
|
|
2453
2594
|
* @param {Object} config - same shape as {@link createClient}
|
|
2454
2595
|
* @returns {SynthigyClient} the installed client (for introspection)
|
|
2455
2596
|
*/
|
|
2456
|
-
export function connect(config) {
|
|
2597
|
+
export function connect(config = {}) {
|
|
2457
2598
|
const prev = _defaultClient
|
|
2458
2599
|
_defaultClient = new SynthigyClient(config)
|
|
2459
2600
|
if (prev) prev.close() // destroy the old one, server-restart style
|
|
@@ -2510,12 +2651,16 @@ export { _delete as delete }
|
|
|
2510
2651
|
// Schema / introspection
|
|
2511
2652
|
export const schema = (...a) => _dflt().schema(...a)
|
|
2512
2653
|
export const lint = (...a) => _dflt().lint(...a)
|
|
2654
|
+
export const compile = (...a) => _dflt().compile(...a)
|
|
2513
2655
|
export const onboard = (...a) => _dflt().onboard(...a)
|
|
2514
2656
|
export const onboardComplete = (...a) => _dflt().onboardComplete(...a)
|
|
2657
|
+
export const deploy = (...a) => _dflt().deploy(...a)
|
|
2658
|
+
export const destroy = (...a) => _dflt().destroy(...a)
|
|
2515
2659
|
export const deployedModel = (...a) => _dflt().deployedModel(...a)
|
|
2516
2660
|
export const runtimeModel = (...a) => _dflt().runtimeModel(...a)
|
|
2517
2661
|
export const token = (...a) => _dflt().token(...a)
|
|
2518
2662
|
export const history = () => _dflt().history
|
|
2663
|
+
export const login = () => _dflt().login
|
|
2519
2664
|
|
|
2520
2665
|
// Streaming + live watches
|
|
2521
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
|
+
}
|