@eduwork/dsh-oidc 0.3.0-dev.2 → 0.3.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/CHANGELOG.en.md +6 -0
- package/CHANGELOG.md +6 -0
- package/README.md +9 -1
- package/README_EN.md +9 -1
- package/THIRD_PARTY_NOTICES.en.md +5 -4
- package/THIRD_PARTY_NOTICES.md +5 -5
- package/docs/account-extensions.en.md +7 -1
- package/docs/account-extensions.md +7 -1
- package/docs/compatibility.en.md +3 -1
- package/docs/compatibility.md +3 -1
- package/docs/development.en.md +1 -1
- package/docs/development.md +1 -1
- package/docs/dsh-integration.en.md +3 -3
- package/docs/dsh-integration.md +3 -3
- package/docs/enterprise-profile.en.md +47 -0
- package/docs/enterprise-profile.md +47 -0
- package/docs/gateway-auth/README.md +6 -5
- package/docs/gateway-auth/README_EN.md +6 -5
- package/docs/gateway-auth/experimental-oidc-llm.en.md +9 -7
- package/docs/gateway-auth/experimental-oidc-llm.md +8 -6
- package/docs/gateway-auth/litellm-setup.en.md +9 -3
- package/docs/gateway-auth/litellm-setup.md +9 -3
- package/docs/gateway-auth/oidc-llm-draft.en.md +89 -63
- package/docs/gateway-auth/oidc-llm-draft.md +86 -60
- package/docs/getting-started.en.md +3 -1
- package/docs/getting-started.md +3 -1
- package/docs/key-binding-protocol.en.md +1 -1
- package/docs/key-binding-protocol.md +1 -1
- package/docs/oidc-interoperability.en.md +1 -1
- package/docs/oidc-interoperability.md +1 -1
- package/docs/open-integration.en.md +3 -1
- package/docs/open-integration.md +3 -1
- package/examples/ecnu.enterprise-profile.example.json +2 -0
- package/examples/enterprise-profile.example.json +2 -0
- package/examples/litellm.enterprise-profile.example.json +9 -1
- package/examples/oidc-llm.enterprise-profile.example.json +33 -1
- package/examples/resources-discovery.example.json +7 -1
- package/lib/callback-page.js +9 -4
- package/lib/client.js +460 -360
- package/lib/desktop-oidc.js +2 -2
- package/lib/gateway-backend.js +20 -5
- package/lib/index.js +8 -6
- package/lib/litellm-protocol.js +2 -1
- package/lib/model-types.js +7 -0
- package/lib/oidc-llm-protocol.js +6 -2
- package/lib/oidc.js +19 -13
- package/lib/profile.js +24 -9
- package/lib/provider/authorization-scope.js +4 -0
- package/lib/provider/core.js +3 -3
- package/lib/provider/index.js +18 -2
- package/lib/provider/transform-adapter.js +4 -0
- package/lib/resources.js +12 -4
- package/lib/token-lifetime.js +15 -0
- package/lib/typert-schemas.js +14 -12
- package/package.json +104 -97
- package/protocol/resources.openapi.yaml +11 -3
- package/schema/enterprise-profile.v1alpha1.schema.json +36 -0
package/lib/desktop-oidc.js
CHANGED
|
@@ -6,7 +6,7 @@ import { callbackLanguage, callbackPage } from './callback-page.js'
|
|
|
6
6
|
const MAX_FLOWS = 32
|
|
7
7
|
const MAX_HISTORY = 128
|
|
8
8
|
const error = (code, message) => Object.assign(new Error(message), { code })
|
|
9
|
-
const safeCodes = new Set(['oidc_callback_invalid', 'oidc_authorization_rejected', 'oidc_token_invalid', 'oidc_id_token_invalid', 'oidc_userinfo_invalid', 'gateway_token_invalid', 'gateway_scope_changed', 'gateway_identity_changed', 'gateway_callback_issuer_missing', 'gateway_callback_issuer_invalid'])
|
|
9
|
+
const safeCodes = new Set(['oidc_id_token_time_invalid', 'oidc_callback_invalid', 'oidc_authorization_rejected', 'oidc_token_invalid', 'oidc_id_token_invalid', 'oidc_userinfo_invalid', 'gateway_token_invalid', 'gateway_scope_changed', 'gateway_identity_changed', 'gateway_callback_issuer_missing', 'gateway_callback_issuer_invalid'])
|
|
10
10
|
|
|
11
11
|
function reply(response, status, profile, outcome, language) {
|
|
12
12
|
const page = callbackPage(profile, outcome, language)
|
|
@@ -138,7 +138,7 @@ export class DesktopOidcBackend extends WebOidcBackend {
|
|
|
138
138
|
this.ctx.logger.warn('Desktop OIDC host credentials are unavailable')
|
|
139
139
|
})
|
|
140
140
|
await attempt.processing
|
|
141
|
-
const failedOutcome = ['gateway_callback_issuer_missing', 'gateway_callback_issuer_invalid'].includes(attempt.errorCode) ? 'issuer-invalid' : 'failed'
|
|
141
|
+
const failedOutcome = ['gateway_callback_issuer_missing', 'gateway_callback_issuer_invalid'].includes(attempt.errorCode) ? 'issuer-invalid' : attempt.errorCode === 'oidc_id_token_time_invalid' ? 'time-invalid' : 'failed'
|
|
142
142
|
if (!response.destroyed) reply(response, attempt.state === 'completed' ? 200 : 400, profile,
|
|
143
143
|
attempt.state === 'completed' ? 'completed' : failedOutcome, language)
|
|
144
144
|
await this.closeCallback(attempt)
|
package/lib/gateway-backend.js
CHANGED
|
@@ -6,6 +6,7 @@ import { gatewayJSON, liteLLMToken, protocolError, readGatewayJSON, registerLite
|
|
|
6
6
|
import { detectGatewayProtocol } from './gateway-protocol.js'
|
|
7
7
|
import { oidcLlmIdentity, oidcLlmToken } from './oidc-llm-protocol.js'
|
|
8
8
|
import { bufferResourceResponse, validateResourceRead } from './model-resource-transport.js'
|
|
9
|
+
import { accessTokenNeedsRefresh } from './token-lifetime.js'
|
|
9
10
|
|
|
10
11
|
const seconds = backend => Math.floor(backend.now() / 1000)
|
|
11
12
|
const fingerprint = (profile, descriptor) => createHash('sha256').update(JSON.stringify({ auth: profile.auth, descriptor })).digest('hex')
|
|
@@ -266,7 +267,7 @@ function withGatewayAuth(Base) {
|
|
|
266
267
|
const epoch = this.accountEpoch(profile)
|
|
267
268
|
const session = await this.loadSession(profile)
|
|
268
269
|
if (!session) return undefined
|
|
269
|
-
if (session
|
|
270
|
+
if (!accessTokenNeedsRefresh(session, this.now)) return session
|
|
270
271
|
try { return await this.refresh(profile, session) }
|
|
271
272
|
catch (cause) {
|
|
272
273
|
// Brief outages must not discard a still-valid access token; expired tokens stop.
|
|
@@ -314,14 +315,26 @@ function withGatewayAuth(Base) {
|
|
|
314
315
|
return account(profile, session, true, 'access_token')
|
|
315
316
|
}
|
|
316
317
|
|
|
317
|
-
async authorizedFetch(profileID, endpoint, init = {}) {
|
|
318
|
+
async authorizedFetch(profileID, endpoint, init = {}, authorization = {}) {
|
|
319
|
+
const retryUnauthorized = authorization.retryUnauthorized === true
|
|
320
|
+
if (retryUnauthorized && init.body != null && typeof init.body !== 'string') throw new TypeError('Authorization retry requires a buffered text request body')
|
|
318
321
|
const profile = this.profile(profileID)
|
|
319
322
|
if (!profile.auth) return super.authorizedFetch(profileID, endpoint, init)
|
|
320
323
|
const epoch = this.accountEpoch(profile), descriptor = await this.discover(profile)
|
|
321
324
|
const target = new URL(endpoint)
|
|
322
325
|
// Host-only model and own-account routes, never key administration or arbitrary origins.
|
|
323
326
|
const modelBase = new URL(`${descriptor.baseURL}/`)
|
|
324
|
-
|
|
327
|
+
// Trusted Host services can grant one exact issuer POST route for their
|
|
328
|
+
// own request. This option is never accepted through a renderer RPC or
|
|
329
|
+
// persisted as a broader model-resource allowlist.
|
|
330
|
+
const servicePath = authorization.issuerServicePath
|
|
331
|
+
const issuerService = typeof servicePath === 'string'
|
|
332
|
+
&& /^\/(?:[a-zA-Z0-9_-]+\/)*[a-zA-Z0-9_-]+$/.test(servicePath)
|
|
333
|
+
&& (init.method ?? 'GET').toUpperCase() === 'POST'
|
|
334
|
+
&& target.href === new URL(descriptor.issuer).origin + servicePath
|
|
335
|
+
const modelResource = target.origin === modelBase.origin
|
|
336
|
+
&& (target.href === descriptor.userInfoEndpoint || target.pathname.startsWith(modelBase.pathname))
|
|
337
|
+
if (target.username || target.password || target.hash || !(modelResource || issuerService)) throw protocolError('oidc_authorized_origin_denied', 'Gateway token destination is not an authorized resource')
|
|
325
338
|
const scope = await this.createGatewayScope(profileID)
|
|
326
339
|
const lease = scope.open(init.signal ?? AbortSignal.timeout(20_000))
|
|
327
340
|
let response
|
|
@@ -331,8 +344,9 @@ function withGatewayAuth(Base) {
|
|
|
331
344
|
if (!session) throw protocolError('oidc_login_required', 'Gateway sign-in is required')
|
|
332
345
|
const request = current => this.fetch(target.href, { ...init, headers: { ...Object.fromEntries(new Headers(init.headers)), authorization: `Bearer ${current.accessToken}` }, redirect: 'error', signal: lease.signal })
|
|
333
346
|
response = await request(session)
|
|
334
|
-
//
|
|
335
|
-
|
|
347
|
+
// Reads recover automatically. Trusted Host services may explicitly opt
|
|
348
|
+
// in for replayable calls; model generation does not opt in.
|
|
349
|
+
if (response.status === 401 && (retryUnauthorized || ['GET', 'HEAD'].includes((init.method ?? 'GET').toUpperCase()))) {
|
|
336
350
|
await response.body?.cancel()
|
|
337
351
|
session = await this.refresh(profile, session)
|
|
338
352
|
lease.assertCurrent()
|
|
@@ -379,6 +393,7 @@ function withGatewayAuth(Base) {
|
|
|
379
393
|
this.updateProvider(next)
|
|
380
394
|
this.profiles.set(profileID, next)
|
|
381
395
|
result.models = models
|
|
396
|
+
if (models.some(model => model.type === 'unknown')) result.issues.push('model_types_unresolved')
|
|
382
397
|
} catch (cause) {
|
|
383
398
|
if (cause.code === 'oidc_login_cancelled' || cause.code === 'oidc_login_required') throw cause
|
|
384
399
|
result.issues.push('models_unavailable')
|
package/lib/index.js
CHANGED
|
@@ -45,7 +45,7 @@ export class OidcAccountService extends TypertRemoteService {
|
|
|
45
45
|
})
|
|
46
46
|
const backendOptions = {
|
|
47
47
|
authorizedOrigins: config.authorizedOrigins,
|
|
48
|
-
accountChanged: status => this.notifyAccount(status),
|
|
48
|
+
accountChanged: status => this.notifyAccount(status, { force: true }),
|
|
49
49
|
updateProvider: profile => {
|
|
50
50
|
const profiles = new Map(this.profiles)
|
|
51
51
|
profiles.set(profile.id, profile)
|
|
@@ -98,18 +98,20 @@ export class OidcAccountService extends TypertRemoteService {
|
|
|
98
98
|
return { opened: true }
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
notifyAccount(status) {
|
|
102
|
-
|
|
101
|
+
notifyAccount(status, { force = false } = {}) {
|
|
102
|
+
// A completed sign-in replaces authorization even when both sessions are
|
|
103
|
+
// "connected". Consumers must reset work blocked by the previous session.
|
|
104
|
+
if (force || this.accountStates.get(status.profileID) !== status.state) {
|
|
103
105
|
this.accountStates.set(status.profileID, status.state)
|
|
104
|
-
this.ctx.emit('oidc/accounts-changed', { profileID: status.profileID, state: status.state })
|
|
106
|
+
this.ctx.emit('oidc/accounts-changed', { profileID: status.profileID, state: status.state, ...(force ? { authorizationChanged: true } : {}) })
|
|
105
107
|
}
|
|
106
108
|
return status
|
|
107
109
|
}
|
|
108
110
|
async status(profileID) { return this.notifyAccount(await this.callBackend('status', profileID)) }
|
|
109
111
|
resources(profileID) { return this.callBackend('resources', profileID) }
|
|
110
112
|
// Host-only capability: deliberately absent from Remote markers and Typert.
|
|
111
|
-
authorizedFetch(profileID, endpoint, init) {
|
|
112
|
-
return this.callBackend('authorizedFetch', profileID, endpoint, init)
|
|
113
|
+
authorizedFetch(profileID, endpoint, init, authorization) {
|
|
114
|
+
return this.callBackend('authorizedFetch', profileID, endpoint, init, authorization)
|
|
113
115
|
}
|
|
114
116
|
modelResourceFetch(profileID, relativePath, options) {
|
|
115
117
|
return this.callBackend('modelResourceFetch', profileID, relativePath, options)
|
package/lib/litellm-protocol.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { serviceProtocolAllowed } from './transport.js'
|
|
2
|
+
import { accessTokenTiming } from './token-lifetime.js'
|
|
2
3
|
// LiteLLM v1.101.0, native CLI auth contract 1. This is OAuth, not OIDC.
|
|
3
4
|
// Protocol-specific wire fields stay here; tokens never cross the renderer RPC.
|
|
4
5
|
const MAX_BYTES = 1024 * 1024
|
|
@@ -99,7 +100,7 @@ export function liteLLMToken(raw, now, previous) {
|
|
|
99
100
|
throw protocolError('gateway_token_invalid', 'LiteLLM token response is incomplete or invalid')
|
|
100
101
|
}
|
|
101
102
|
if (previous && (raw.user_id !== previous.identity.sub || raw.team_id !== previous.teamID)) throw protocolError('gateway_identity_changed', 'Gateway authorization identity changed during refresh; sign in again')
|
|
102
|
-
return { accessToken: raw.access_token, refreshToken: raw.refresh_token,
|
|
103
|
+
return { accessToken: raw.access_token, refreshToken: raw.refresh_token, ...accessTokenTiming(raw.expires_in, now), teamID: raw.team_id, identity: { sub: raw.user_id, name: previous?.identity?.name || raw.user_id } }
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
export function tokenRequest(fetcher, descriptor, values) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Purpose is independent of input modalities: a vision LLM is still an LLM.
|
|
2
|
+
// Unknown (including future server types) is never registered for conversation.
|
|
3
|
+
export const MODEL_TYPES = Object.freeze(['llm', 'embedding', 'rerank', 'image', 'tts', 'unknown'])
|
|
4
|
+
|
|
5
|
+
export function isLLM(model) {
|
|
6
|
+
return model.type === 'llm'
|
|
7
|
+
}
|
package/lib/oidc-llm-protocol.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { serviceProtocolAllowed } from './transport.js'
|
|
2
|
+
import { accessTokenTiming } from './token-lifetime.js'
|
|
2
3
|
// Experimental draft 0.1. These wire rules are not a published standard.
|
|
3
4
|
import { protocolError } from './litellm-protocol.js'
|
|
4
5
|
|
|
@@ -30,9 +31,12 @@ export function detectOidcLlm(raw, profile) {
|
|
|
30
31
|
const identityMode = profile.auth.identityMode
|
|
31
32
|
includes(extension, 'identity_modes_supported', ['oauth', identityMode])
|
|
32
33
|
includes(extension, 'client_registration_methods_supported', ['static'])
|
|
33
|
-
const
|
|
34
|
+
const identityScopes = identityMode === 'oidc'
|
|
34
35
|
? ['openid', 'profile', 'offline_access', 'llm:models:read', 'llm:invoke']
|
|
35
36
|
: ['llm:profile', 'llm:models:read', 'llm:invoke']
|
|
37
|
+
// Only request explicitly configured services; discovery is not consent to
|
|
38
|
+
// every capability advertised by the authorization server.
|
|
39
|
+
const scopes = [...new Set([...identityScopes, ...(profile.auth.additionalScopes ?? [])])]
|
|
36
40
|
for (const [key, values] of Object.entries({
|
|
37
41
|
response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
38
42
|
code_challenge_methods_supported: ['S256'], token_endpoint_auth_methods_supported: ['none'],
|
|
@@ -73,7 +77,7 @@ export function oidcLlmToken(raw, descriptor, now, previous) {
|
|
|
73
77
|
throw protocolError('gateway_scope_changed', 'oidc-llm granted scopes do not match the requested authorization')
|
|
74
78
|
}
|
|
75
79
|
// No unapproved 15-minute ceiling: honor the actual advertised TTL.
|
|
76
|
-
return { accessToken: raw.access_token, refreshToken: raw.refresh_token,
|
|
80
|
+
return { accessToken: raw.access_token, refreshToken: raw.refresh_token, ...accessTokenTiming(raw.expires_in, now), scopes, teamID: null }
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
export function oidcLlmIdentity(raw) {
|
package/lib/oidc.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { serviceProtocolAllowed } from './transport.js'
|
|
2
2
|
import { createHash, createPublicKey, randomBytes, timingSafeEqual, verify as verifySignature } from 'node:crypto'
|
|
3
3
|
import { emptyResources } from './resources.js'
|
|
4
|
+
import { accessTokenNeedsRefresh, accessTokenTiming } from './token-lifetime.js'
|
|
4
5
|
|
|
5
6
|
const FLOW_TTL_MS = 10 * 60 * 1000
|
|
6
7
|
const MAX_PENDING_FLOWS = 32
|
|
7
8
|
const CLOCK_SKEW_SECONDS = 60
|
|
8
|
-
const ACCESS_REFRESH_SECONDS = 90
|
|
9
9
|
const MAX_RESPONSE_BYTES = 1024 * 1024
|
|
10
10
|
export const OIDC_CALLBACK_PATH = '/oauth/callback'
|
|
11
11
|
|
|
@@ -225,7 +225,7 @@ export class WebOidcBackend {
|
|
|
225
225
|
let response
|
|
226
226
|
try {
|
|
227
227
|
response = await this.fetch(discoveryURL(profile.oidc.issuer), {
|
|
228
|
-
headers: { accept: 'application/json' }, signal: AbortSignal.timeout(20_000),
|
|
228
|
+
headers: { accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(20_000),
|
|
229
229
|
})
|
|
230
230
|
} catch (cause) {
|
|
231
231
|
throw publicError('oidc_discovery_failed', 'unable to read OIDC discovery metadata', cause)
|
|
@@ -328,7 +328,7 @@ export class WebOidcBackend {
|
|
|
328
328
|
})
|
|
329
329
|
const response = await this.fetch(discovery.tokenEndpoint, {
|
|
330
330
|
method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
331
|
-
body, signal: AbortSignal.timeout(20_000),
|
|
331
|
+
body, redirect: 'error', signal: AbortSignal.timeout(20_000),
|
|
332
332
|
})
|
|
333
333
|
const token = await responseJSON(response, 'oidc_token_request_failed')
|
|
334
334
|
if (typeof token.access_token !== 'string' || typeof token.id_token !== 'string' || token.token_type?.toLowerCase() !== 'bearer') {
|
|
@@ -336,6 +336,7 @@ export class WebOidcBackend {
|
|
|
336
336
|
}
|
|
337
337
|
const expiresIn = token.expires_in === undefined ? 3600 : Number(token.expires_in)
|
|
338
338
|
if (!Number.isFinite(expiresIn) || expiresIn <= 0) throw publicError('oidc_token_invalid', 'OIDC token expiry is invalid')
|
|
339
|
+
const timing = accessTokenTiming(expiresIn, this.now)
|
|
339
340
|
const claims = await this.verifyIDToken(profile, discovery, token.id_token, nonce, token.access_token)
|
|
340
341
|
const userInfo = await this.userInfo(discovery.userInfoEndpoint, token.access_token)
|
|
341
342
|
if (!equalText(userInfo.sub, claims.sub)) throw publicError('oidc_userinfo_invalid', 'OIDC UserInfo subject does not match ID Token')
|
|
@@ -346,7 +347,7 @@ export class WebOidcBackend {
|
|
|
346
347
|
clientId: profile.oidc.clientId,
|
|
347
348
|
accessToken: token.access_token,
|
|
348
349
|
refreshToken: typeof token.refresh_token === 'string' ? token.refresh_token : '',
|
|
349
|
-
|
|
350
|
+
...timing,
|
|
350
351
|
identity: {
|
|
351
352
|
sub: claims.sub,
|
|
352
353
|
name: userName,
|
|
@@ -387,9 +388,12 @@ export class WebOidcBackend {
|
|
|
387
388
|
throw publicError('oidc_id_token_invalid', 'OIDC ID Token identity claims are invalid')
|
|
388
389
|
}
|
|
389
390
|
if (!Number.isFinite(claims.exp) || !Number.isFinite(claims.iat)
|
|
390
|
-
|| claims.
|
|
391
|
-
|
|
392
|
-
|
|
391
|
+
|| (claims.nbf !== undefined && !Number.isFinite(claims.nbf))) {
|
|
392
|
+
throw publicError('oidc_id_token_invalid', 'OIDC ID Token time claims are malformed')
|
|
393
|
+
}
|
|
394
|
+
if (claims.exp < now - CLOCK_SKEW_SECONDS || claims.iat > now + CLOCK_SKEW_SECONDS
|
|
395
|
+
|| (claims.nbf !== undefined && claims.nbf > now + CLOCK_SKEW_SECONDS)) {
|
|
396
|
+
throw publicError('oidc_id_token_time_invalid', 'OIDC ID Token time claims are invalid')
|
|
393
397
|
}
|
|
394
398
|
if (claims.at_hash !== undefined && (typeof claims.at_hash !== 'string' || !verifyAccessTokenHash(accessToken, claims.at_hash))) {
|
|
395
399
|
throw publicError('oidc_id_token_invalid', 'OIDC ID Token access-token hash is invalid')
|
|
@@ -399,7 +403,7 @@ export class WebOidcBackend {
|
|
|
399
403
|
|
|
400
404
|
async userInfo(endpoint, accessToken) {
|
|
401
405
|
const response = await this.fetch(endpoint, {
|
|
402
|
-
headers: { authorization: `Bearer ${accessToken}`, accept: 'application/json' }, signal: AbortSignal.timeout(20_000),
|
|
406
|
+
headers: { authorization: `Bearer ${accessToken}`, accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(20_000),
|
|
403
407
|
})
|
|
404
408
|
const result = await responseJSON(response, 'oidc_userinfo_failed')
|
|
405
409
|
if (typeof result.sub !== 'string' || result.sub === '') throw publicError('oidc_userinfo_invalid', 'OIDC UserInfo has no subject')
|
|
@@ -459,13 +463,14 @@ export class WebOidcBackend {
|
|
|
459
463
|
const epoch = this.accountEpoch(profile)
|
|
460
464
|
const saved = await this.loadSession(profile)
|
|
461
465
|
if (!saved) throw publicError('oidc_login_required', 'OIDC session is no longer available')
|
|
462
|
-
if (saved
|
|
466
|
+
if (saved.accessToken !== session.accessToken && saved.expiresAt > nowSeconds(this.now)) return saved
|
|
467
|
+
session = saved
|
|
463
468
|
if (!session.refreshToken) throw publicError('oidc_login_required', 'OIDC session cannot be refreshed')
|
|
464
469
|
const discovery = await this.discover(profile)
|
|
465
470
|
const response = await this.fetch(discovery.tokenEndpoint, {
|
|
466
471
|
method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
467
472
|
body: new URLSearchParams({ grant_type: 'refresh_token', client_id: profile.oidc.clientId, refresh_token: session.refreshToken }),
|
|
468
|
-
signal: AbortSignal.timeout(20_000),
|
|
473
|
+
redirect: 'error', signal: AbortSignal.timeout(20_000),
|
|
469
474
|
})
|
|
470
475
|
let token
|
|
471
476
|
try { token = await responseJSON(response, 'oidc_refresh_failed') }
|
|
@@ -486,7 +491,7 @@ export class WebOidcBackend {
|
|
|
486
491
|
...session,
|
|
487
492
|
accessToken: token.access_token,
|
|
488
493
|
refreshToken: typeof token.refresh_token === 'string' ? token.refresh_token : session.refreshToken,
|
|
489
|
-
|
|
494
|
+
...accessTokenTiming(expiresIn, this.now),
|
|
490
495
|
}
|
|
491
496
|
await this.saveSession(profile, next, epoch)
|
|
492
497
|
return next
|
|
@@ -496,8 +501,9 @@ export class WebOidcBackend {
|
|
|
496
501
|
const epoch = this.accountEpoch(profile)
|
|
497
502
|
let session = await this.loadSession(profile)
|
|
498
503
|
if (!session) return undefined
|
|
499
|
-
if (session
|
|
504
|
+
if (accessTokenNeedsRefresh(session, this.now)) {
|
|
500
505
|
if (!session.refreshToken) {
|
|
506
|
+
if (session.expiresAt > nowSeconds(this.now)) return session
|
|
501
507
|
await this.clearCredentials(profile, epoch)
|
|
502
508
|
this.accountChanged(account(profile, undefined, false))
|
|
503
509
|
return undefined
|
|
@@ -595,7 +601,7 @@ export class WebOidcBackend {
|
|
|
595
601
|
const token = session.refreshToken || session.accessToken
|
|
596
602
|
await this.fetch(discovery.revocationEndpoint, {
|
|
597
603
|
method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
598
|
-
body: new URLSearchParams({ token, client_id: profile.oidc.clientId }), signal: AbortSignal.timeout(10_000),
|
|
604
|
+
body: new URLSearchParams({ token, client_id: profile.oidc.clientId }), redirect: 'error', signal: AbortSignal.timeout(10_000),
|
|
599
605
|
})
|
|
600
606
|
}
|
|
601
607
|
} catch (cause) { this.ctx.logger.warn(cause instanceof Error ? cause : new Error(String(cause))) }
|
package/lib/profile.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { serviceProtocolAllowed } from './transport.js'
|
|
2
|
+
import { MODEL_TYPES, isLLM } from './model-types.js'
|
|
2
3
|
import { readFileSync } from 'node:fs'
|
|
3
4
|
|
|
4
5
|
export const PROFILE_SCHEMA_VERSION = 'dsh-oidc/v1alpha1'
|
|
@@ -19,7 +20,7 @@ const allowedRootKeys = new Set([
|
|
|
19
20
|
const allowedOidcKeys = new Set(['issuer', 'clientId', 'scopes'])
|
|
20
21
|
const allowedBrandKeys = new Set([
|
|
21
22
|
'productName', 'organizationName', 'mark', 'logoURL', 'primaryColor',
|
|
22
|
-
'loginTitle', 'loginDescription', 'supportURL',
|
|
23
|
+
'loginTitle', 'loginDescription', 'loginButtonLabel', 'supportURL', 'language',
|
|
23
24
|
])
|
|
24
25
|
const allowedProviderKeys = new Set([
|
|
25
26
|
'id', 'displayName', 'adapter', 'baseURL', 'reasoning', 'defaultContextWindow',
|
|
@@ -27,7 +28,7 @@ const allowedProviderKeys = new Set([
|
|
|
27
28
|
'requestImageMaxBytes', 'streamIdleTimeoutMs', 'retryPolicy', 'compat', 'models', 'modelSource',
|
|
28
29
|
])
|
|
29
30
|
const allowedModelKeys = new Set([
|
|
30
|
-
'id', 'name', 'input', 'contextWindow', 'maxTokens', 'reasoning',
|
|
31
|
+
'id', 'type', 'name', 'input', 'contextWindow', 'maxTokens', 'reasoning',
|
|
31
32
|
'reasoningEfforts', 'defaultReasoningEffort', 'compat',
|
|
32
33
|
])
|
|
33
34
|
const allowedCompatKeys = new Set([
|
|
@@ -114,10 +115,14 @@ function normalizeBrand(value = {}) {
|
|
|
114
115
|
}
|
|
115
116
|
result.logoURL = logo.startsWith('https://') ? safeHTTPSResource(logo, 'brand.logoURL', 128 * 1024) : logo
|
|
116
117
|
}
|
|
117
|
-
for (const key of
|
|
118
|
-
if (source[key] !== undefined) result[key] = text(source[key], `brand.${key}`,
|
|
118
|
+
for (const [key, max] of Object.entries({ loginTitle: 120, loginDescription: 500, loginButtonLabel: 40 })) {
|
|
119
|
+
if (source[key] !== undefined) result[key] = text(source[key], `brand.${key}`, max)
|
|
119
120
|
}
|
|
120
121
|
if (source.supportURL !== undefined) result.supportURL = exactURL(source.supportURL, 'brand.supportURL')
|
|
122
|
+
if (source.language !== undefined) {
|
|
123
|
+
if (!['auto', 'zh-CN', 'en'].includes(source.language)) throw new Error('brand.language must be auto, zh-CN or en')
|
|
124
|
+
result.language = source.language
|
|
125
|
+
}
|
|
121
126
|
return Object.freeze(result)
|
|
122
127
|
}
|
|
123
128
|
|
|
@@ -162,6 +167,8 @@ export function normalizeModels(models, providerID) {
|
|
|
162
167
|
const model = object(raw, label)
|
|
163
168
|
exactKeys(model, allowedModelKeys, label)
|
|
164
169
|
const id = text(model.id, `${label}.id`, 256)
|
|
170
|
+
const type = model.type === undefined ? 'unknown' : model.type
|
|
171
|
+
if (!MODEL_TYPES.includes(type)) throw new Error(`${label}.type must be one of ${MODEL_TYPES.join(', ')}`)
|
|
165
172
|
if (seen.has(id)) throw new Error(`${providerID} repeats model ${id}`)
|
|
166
173
|
seen.add(id)
|
|
167
174
|
const input = model.input ?? ['text']
|
|
@@ -184,6 +191,7 @@ export function normalizeModels(models, providerID) {
|
|
|
184
191
|
}
|
|
185
192
|
return Object.freeze({
|
|
186
193
|
id,
|
|
194
|
+
type,
|
|
187
195
|
name: model.name === undefined ? id : text(model.name, `${providerID}.${id}.name`, 256),
|
|
188
196
|
input: Object.freeze([...new Set(input)]),
|
|
189
197
|
...(model.contextWindow === undefined ? {} : { contextWindow: positiveInteger(model.contextWindow, `${providerID}.${id}.contextWindow`) }),
|
|
@@ -258,15 +266,22 @@ export function normalizeEnterpriseProfile(raw) {
|
|
|
258
266
|
let auth
|
|
259
267
|
if (gateway) {
|
|
260
268
|
const rawAuth = object(source.auth, 'profile.auth')
|
|
261
|
-
exactKeys(rawAuth, new Set(['discoveryUrl', 'expectedIssuer', 'experimentalOidcLlm', 'clientId', 'identityMode']), 'profile.auth')
|
|
269
|
+
exactKeys(rawAuth, new Set(['discoveryUrl', 'expectedIssuer', 'experimentalOidcLlm', 'clientId', 'identityMode', 'additionalScopes']), 'profile.auth')
|
|
262
270
|
const experimental = rawAuth.experimentalOidcLlm === true
|
|
263
271
|
if (rawAuth.experimentalOidcLlm !== undefined && typeof rawAuth.experimentalOidcLlm !== 'boolean') throw new Error('profile.auth.experimentalOidcLlm must be boolean')
|
|
264
|
-
if (!experimental && (rawAuth.clientId !== undefined || rawAuth.identityMode !== undefined)) throw new Error('profile.auth clientId and
|
|
272
|
+
if (!experimental && (rawAuth.clientId !== undefined || rawAuth.identityMode !== undefined || rawAuth.additionalScopes !== undefined)) throw new Error('profile.auth clientId, identityMode and additionalScopes require experimentalOidcLlm=true')
|
|
265
273
|
if (experimental && !['oauth', 'oidc'].includes(rawAuth.identityMode)) throw new Error('profile.auth.identityMode must explicitly select oauth or oidc')
|
|
274
|
+
if (rawAuth.additionalScopes !== undefined && (!Array.isArray(rawAuth.additionalScopes)
|
|
275
|
+
|| rawAuth.additionalScopes.length > 64
|
|
276
|
+
|| rawAuth.additionalScopes.some(scope => typeof scope !== 'string' || scope.length > 128 || !/^[\x21\x23-\x5b\x5d-\x7e]+$/.test(scope))
|
|
277
|
+
|| new Set(rawAuth.additionalScopes).size !== rawAuth.additionalScopes.length)) {
|
|
278
|
+
throw new Error('profile.auth.additionalScopes must contain at most 64 unique OAuth scope tokens, each at most 128 characters')
|
|
279
|
+
}
|
|
266
280
|
auth = Object.freeze({
|
|
267
281
|
discoveryUrl: issuerURL(rawAuth.discoveryUrl, 'profile.auth.discoveryUrl', allowInsecureDevelopment),
|
|
268
282
|
...(rawAuth.expectedIssuer === undefined ? {} : { expectedIssuer: issuerURL(rawAuth.expectedIssuer, 'profile.auth.expectedIssuer', allowInsecureDevelopment) }),
|
|
269
283
|
...(experimental ? { experimentalOidcLlm: true, clientId: text(rawAuth.clientId, 'profile.auth.clientId', 256), identityMode: rawAuth.identityMode } : {}),
|
|
284
|
+
...(rawAuth.additionalScopes === undefined ? {} : { additionalScopes: Object.freeze([...rawAuth.additionalScopes]) }),
|
|
270
285
|
})
|
|
271
286
|
if (source.provider?.baseURL !== undefined) throw new Error('profile.auth derives provider.baseURL from validated discovery')
|
|
272
287
|
if (source.provider?.modelSource !== undefined && source.provider.modelSource !== 'discovery') throw new Error('profile.auth requires discovery modelSource')
|
|
@@ -354,7 +369,7 @@ export function loadEnterpriseProfiles(raw = {}, environment = process.env) {
|
|
|
354
369
|
|
|
355
370
|
export function enterpriseProviderConfig(profiles) {
|
|
356
371
|
return {
|
|
357
|
-
providers: Object.fromEntries([...profiles.values()].filter(profile => profile.provider?.baseURL && profile.provider.models.
|
|
372
|
+
providers: Object.fromEntries([...profiles.values()].filter(profile => profile.provider?.baseURL && profile.provider.models.some(isLLM)).map(profile => [profile.provider.id, {
|
|
358
373
|
displayName: profile.provider.displayName,
|
|
359
374
|
apiKeyEnv: `DSH_GATEWAY_${profile.id.toUpperCase().replace(/-/g, '_')}_ACCESS`,
|
|
360
375
|
baseURL: profile.provider.baseURL,
|
|
@@ -368,7 +383,7 @@ export function enterpriseProviderConfig(profiles) {
|
|
|
368
383
|
...(profile.provider.streamIdleTimeoutMs === undefined ? {} : { streamIdleTimeoutMs: profile.provider.streamIdleTimeoutMs }),
|
|
369
384
|
...(profile.provider.retryPolicy === undefined ? {} : { retryPolicy: structuredClone(profile.provider.retryPolicy) }),
|
|
370
385
|
...(profile.provider.compat === undefined ? {} : { compat: { ...profile.provider.compat } }),
|
|
371
|
-
models: profile.provider.models.map(model => ({
|
|
386
|
+
models: profile.provider.models.filter(isLLM).map(({ type, ...model }) => ({
|
|
372
387
|
...model,
|
|
373
388
|
input: [...model.input],
|
|
374
389
|
...(model.reasoningEfforts && typeof model.reasoningEfforts === 'object'
|
|
@@ -390,7 +405,7 @@ export function publicProfile(profile) {
|
|
|
390
405
|
...(profile.provider ? { provider: {
|
|
391
406
|
id: profile.provider.id,
|
|
392
407
|
displayName: profile.provider.displayName,
|
|
393
|
-
models: profile.provider.models.map(model => ({ id: model.id, name: model.name, input: model.input })),
|
|
408
|
+
models: profile.provider.models.filter(isLLM).map(model => ({ id: model.id, name: model.name, input: model.input })),
|
|
394
409
|
} } : {}),
|
|
395
410
|
}
|
|
396
411
|
}
|
|
@@ -9,6 +9,10 @@ export class AuthorizationScopedAdapter {
|
|
|
9
9
|
providerInfo(provider) { return this.inner.providerInfo(provider) }
|
|
10
10
|
providerRetryPolicy(provider) { return this.inner.providerRetryPolicy(provider) }
|
|
11
11
|
imageRequestPricing(provider, model) { return this.inner.imageRequestPricing(provider, model) }
|
|
12
|
+
imageRequestTarget(provider, model, image) {
|
|
13
|
+
return this.inner.imageRequestTarget?.(provider, model, image)
|
|
14
|
+
?? { width: image.width, height: image.height, maxBytes: image.bytes }
|
|
15
|
+
}
|
|
12
16
|
listModels(provider) { return this.inner.listModels(provider) }
|
|
13
17
|
resolveModel(provider, model, signal) { return this.inner.resolveModel(provider, model, signal) }
|
|
14
18
|
|
package/lib/provider/core.js
CHANGED
|
@@ -174,16 +174,16 @@ export function resolveEnterpriseProfiles(raw = {}) {
|
|
|
174
174
|
})
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
export function configurableEntries(profiles) {
|
|
177
|
+
export function configurableEntries(profiles, settingsNs = ENTERPRISE_SETTINGS_NAMESPACE) {
|
|
178
178
|
return [...profiles.values()].map(profile => ({
|
|
179
179
|
provider: profile.provider,
|
|
180
180
|
displayName: profile.displayName,
|
|
181
|
-
settingsNs
|
|
181
|
+
settingsNs,
|
|
182
182
|
settingsPath: ['providers', profile.provider],
|
|
183
183
|
declared: true,
|
|
184
184
|
}))
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
export function settingsBase(rawConfig) {
|
|
188
|
-
return { providers: rawConfig.providers ?? {} }
|
|
188
|
+
return { providers: typeof rawConfig.providers?.get === 'function' ? rawConfig.providers.get() : rawConfig.providers ?? {} }
|
|
189
189
|
}
|
package/lib/provider/index.js
CHANGED
|
@@ -16,9 +16,22 @@ import { AuthorizationScopedAdapter } from './authorization-scope.js'
|
|
|
16
16
|
export const name = 'dsh-oidc-provider'
|
|
17
17
|
export const inject = ['llm']
|
|
18
18
|
export const SETTINGS_NAMESPACE = ENTERPRISE_SETTINGS_NAMESPACE
|
|
19
|
+
export const Config = PiAiConfig
|
|
19
20
|
|
|
20
21
|
function installEnterpriseSettings(ctx, rawConfig, hooks) {
|
|
21
22
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
23
|
+
if (typeof settingsCtx.settings.installSection !== 'function') {
|
|
24
|
+
// Account-managed routes come from the organization profile and its
|
|
25
|
+
// authorization lease. Never project them into editable API-key forms.
|
|
26
|
+
if (rawConfig.settingsEnabled !== true) return
|
|
27
|
+
if (typeof rawConfig.providers?.get !== 'function' || !ctx.fiber.entry) {
|
|
28
|
+
throw new Error('Editable enterprise providers require a DSH profile entry with live Config')
|
|
29
|
+
}
|
|
30
|
+
settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber))
|
|
31
|
+
hooks.setSource(() => settingsBase(rawConfig))
|
|
32
|
+
ctx.on('loader/volatile-update', () => hooks.onChange())
|
|
33
|
+
return
|
|
34
|
+
}
|
|
22
35
|
settingsCtx.settings.installSection(
|
|
23
36
|
ctx,
|
|
24
37
|
SETTINGS_NAMESPACE,
|
|
@@ -147,14 +160,17 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
147
160
|
: transforming
|
|
148
161
|
let registration
|
|
149
162
|
let directory
|
|
163
|
+
const nativeSettings = typeof ctx.get('settings')?.installSection !== 'function'
|
|
164
|
+
const editableDirectory = !nativeSettings || rawConfig.settingsEnabled === true
|
|
165
|
+
const settingsNs = nativeSettings ? ctx.fiber.entry?.options.id ?? SETTINGS_NAMESPACE : SETTINGS_NAMESPACE
|
|
150
166
|
const replaceProfiles = next => {
|
|
151
167
|
const previous = profiles
|
|
152
168
|
profiles = next
|
|
153
169
|
try {
|
|
154
170
|
if (registration) registration.replace([...next.keys()])
|
|
155
171
|
else if (next.size) registration = ctx.llm.registerAdapter([...next.keys()], adapter)
|
|
156
|
-
if (directory) directory.replace(configurableEntries(next))
|
|
157
|
-
else if (next.size) directory = ctx.llm.registerConfigurableProviders(configurableEntries(next))
|
|
172
|
+
if (directory) directory.replace(configurableEntries(next, settingsNs))
|
|
173
|
+
else if (next.size && editableDirectory) directory = ctx.llm.registerConfigurableProviders(configurableEntries(next, settingsNs))
|
|
158
174
|
} catch (error) {
|
|
159
175
|
profiles = previous
|
|
160
176
|
registration?.replace([...previous.keys()])
|
|
@@ -37,6 +37,10 @@ export class TransformingEnterpriseAdapter {
|
|
|
37
37
|
providerInfo(provider) { return this.inner.providerInfo(provider) }
|
|
38
38
|
providerRetryPolicy(provider) { return this.inner.providerRetryPolicy(provider) }
|
|
39
39
|
imageRequestPricing(provider, model) { return this.inner.imageRequestPricing(provider, model) }
|
|
40
|
+
imageRequestTarget(provider, model, image) {
|
|
41
|
+
return this.inner.imageRequestTarget?.(provider, model, image)
|
|
42
|
+
?? { width: image.width, height: image.height, maxBytes: image.bytes }
|
|
43
|
+
}
|
|
40
44
|
|
|
41
45
|
async listModels(provider) {
|
|
42
46
|
const models = await this.inner.listModels(provider)
|
package/lib/resources.js
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
import { normalizeModels } from './profile.js'
|
|
2
|
+
import { MODEL_TYPES } from './model-types.js'
|
|
2
3
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
4
|
+
// A gateway catalog may also contain embedding, ranking and media models.
|
|
5
|
+
// Type metadata classifies authorized rows; it never grants model access.
|
|
5
6
|
export function normalizeResourceModels(raw, profile) {
|
|
6
7
|
if (!Array.isArray(raw?.data) || raw.data.length > 128) throw new Error('invalid model catalog')
|
|
7
8
|
const reviewed = new Map(profile.provider.models.map(model => [model.id, model]))
|
|
8
9
|
const models = raw.data.map(row => {
|
|
9
10
|
const id = typeof row?.id === 'string' ? row.id.trim() : ''
|
|
11
|
+
const local = reviewed.get(id)
|
|
12
|
+
// The current server declaration wins. Local metadata fills an absent type,
|
|
13
|
+
// but cannot turn an explicit unknown/future server type into a chat model.
|
|
14
|
+
const type = row.type === undefined
|
|
15
|
+
? local?.type ?? 'unknown'
|
|
16
|
+
: MODEL_TYPES.includes(row.type) ? row.type : 'unknown'
|
|
10
17
|
// A plain /models response does not prove reasoning or vision support.
|
|
11
|
-
return
|
|
18
|
+
return { ...(local ?? { id, name: id, input: ['text'], reasoning: false }), type }
|
|
12
19
|
})
|
|
13
|
-
|
|
20
|
+
// Keep the full typed catalog; only the DSH conversation projection uses LLMs.
|
|
21
|
+
return models.length === 0 ? Object.freeze([]) : normalizeModels(models, profile.provider.id)
|
|
14
22
|
}
|
|
15
23
|
|
|
16
24
|
export function emptyResources(profile) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const ACCESS_REFRESH_SECONDS = 30 * 60
|
|
2
|
+
|
|
3
|
+
// Refresh early without repeatedly renewing tokens whose entire lifetime is
|
|
4
|
+
// shorter than the normal margin. These timestamps stay in the Host vault.
|
|
5
|
+
export function accessTokenTiming(expiresIn, now) {
|
|
6
|
+
const expiresAt = Math.floor(now() / 1000) + expiresIn
|
|
7
|
+
return { expiresAt, refreshAt: expiresAt - Math.min(ACCESS_REFRESH_SECONDS, expiresIn / 2) }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function accessTokenNeedsRefresh(session, now) {
|
|
11
|
+
// Existing installations have only expiresAt. Their first refresh adds the
|
|
12
|
+
// lifetime-aware timestamp; no migration or new sign-in is required.
|
|
13
|
+
const refreshAt = Number.isFinite(session.refreshAt) ? session.refreshAt : session.expiresAt - ACCESS_REFRESH_SECONDS
|
|
14
|
+
return now() / 1000 >= Math.min(session.expiresAt, refreshAt)
|
|
15
|
+
}
|
package/lib/typert-schemas.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import { MODEL_TYPES } from './model-types.js'
|
|
2
3
|
|
|
3
4
|
const pkg = '@eduwork/dsh-oidc'
|
|
4
5
|
export const profileIdSchema = z.string().min(1).max(64)
|
|
@@ -8,11 +9,11 @@ export const baseURLSchema = z.string().min(1).max(2048)
|
|
|
8
9
|
export const modelCatalogModeSchema = z.enum(['discovery', 'manual'])
|
|
9
10
|
export const configurationTargetSchema = z.enum(['config', 'examples'])
|
|
10
11
|
export const openConfigurationResult = Object.freeze({
|
|
11
|
-
mode: 'strict', typeSymbol: `${pkg}#OpenConfigurationResult`,
|
|
12
|
+
mode: 'strict', create() { return this.schema }, typeSymbol: `${pkg}#OpenConfigurationResult`,
|
|
12
13
|
schema: z.object({ opened: z.literal(true) }).strict(),
|
|
13
14
|
})
|
|
14
15
|
export const enterpriseSelectionResult = Object.freeze({
|
|
15
|
-
mode: 'strict', typeSymbol: `${pkg}#EnterpriseSelectionResult`,
|
|
16
|
+
mode: 'strict', create() { return this.schema }, typeSymbol: `${pkg}#EnterpriseSelectionResult`,
|
|
16
17
|
schema: z.object({ changed: z.boolean(), selection: z.object({ provider: z.string(), model: z.string(), reasoningEffort: z.string().optional() }).strict().optional() }).strict(),
|
|
17
18
|
})
|
|
18
19
|
export const enterpriseSelectionOptionsSchema = z.object({ onlyIfMissing: z.boolean().optional() }).strict()
|
|
@@ -20,7 +21,7 @@ export const enterpriseSelectionOptionsSchema = z.object({ onlyIfMissing: z.bool
|
|
|
20
21
|
const brandSchema = z.object({
|
|
21
22
|
productName: z.string().optional(), organizationName: z.string().optional(), mark: z.string().optional(),
|
|
22
23
|
logoURL: z.string().optional(), primaryColor: z.string().optional(), loginTitle: z.string().optional(),
|
|
23
|
-
loginDescription: z.string().optional(), supportURL: z.string().optional(),
|
|
24
|
+
loginDescription: z.string().optional(), loginButtonLabel: z.string().min(1).max(40).optional(), supportURL: z.string().optional(), language: z.enum(['auto', 'zh-CN', 'en']).optional(),
|
|
24
25
|
}).strict()
|
|
25
26
|
|
|
26
27
|
const publicProfileSchema = z.object({
|
|
@@ -50,11 +51,12 @@ export const runtimeModelSchema = z.object({
|
|
|
50
51
|
}).strict()
|
|
51
52
|
|
|
52
53
|
export const runtimeModelsSchema = z.array(runtimeModelSchema).max(128)
|
|
54
|
+
export const resourceModelSchema = runtimeModelSchema.extend({ type: z.enum(MODEL_TYPES) })
|
|
53
55
|
|
|
54
56
|
export const resourcesResult = Object.freeze({
|
|
55
|
-
mode: 'strict', typeSymbol: `${pkg}#Resources`,
|
|
57
|
+
mode: 'strict', create() { return this.schema }, typeSymbol: `${pkg}#Resources`,
|
|
56
58
|
schema: z.object({
|
|
57
|
-
profileID: z.string(), modelSource: z.string(), models:
|
|
59
|
+
profileID: z.string(), modelSource: z.string(), models: z.array(resourceModelSchema).max(128), issues: z.array(z.string()),
|
|
58
60
|
}).strict(),
|
|
59
61
|
})
|
|
60
62
|
|
|
@@ -69,14 +71,14 @@ const managementProfileSchema = z.object({
|
|
|
69
71
|
}).strict()
|
|
70
72
|
|
|
71
73
|
export const configurationResult = Object.freeze({
|
|
72
|
-
mode: 'strict', typeSymbol: `${pkg}#Configuration`,
|
|
74
|
+
mode: 'strict', create() { return this.schema }, typeSymbol: `${pkg}#Configuration`,
|
|
73
75
|
schema: z.object({ schemaVersion: z.literal('dsh-oidc/v1alpha1'), uiMode: z.enum(['standard', 'models-only', 'external']), profiles: z.array(publicProfileSchema),
|
|
74
76
|
manageProductBrand: z.boolean().optional(), configFile: z.object({ path: z.string(), examplesPath: z.string(), canOpen: z.boolean() }).strict().optional(),
|
|
75
77
|
}).strict(),
|
|
76
78
|
})
|
|
77
79
|
|
|
78
80
|
export const managementResult = Object.freeze({
|
|
79
|
-
mode: 'strict', typeSymbol: `${pkg}#ManagementConfiguration`,
|
|
81
|
+
mode: 'strict', create() { return this.schema }, typeSymbol: `${pkg}#ManagementConfiguration`,
|
|
80
82
|
schema: z.object({
|
|
81
83
|
schemaVersion: z.literal('dsh-oidc/management/v1alpha1'), mode: z.enum(['profile', 'native']),
|
|
82
84
|
activeProfileID: z.string(), restartRequired: z.boolean(),
|
|
@@ -86,14 +88,14 @@ export const managementResult = Object.freeze({
|
|
|
86
88
|
})
|
|
87
89
|
|
|
88
90
|
export const restartResult = Object.freeze({
|
|
89
|
-
mode: 'strict', typeSymbol: `${pkg}#RestartResult`,
|
|
91
|
+
mode: 'strict', create() { return this.schema }, typeSymbol: `${pkg}#RestartResult`,
|
|
90
92
|
schema: z.object({ restarting: z.literal(true) }).strict(),
|
|
91
93
|
})
|
|
92
94
|
|
|
93
|
-
export const accountResult = Object.freeze({ mode: 'strict', typeSymbol: `${pkg}#AccountStatus`, schema: accountSchema })
|
|
95
|
+
export const accountResult = Object.freeze({ mode: 'strict', create() { return this.schema }, typeSymbol: `${pkg}#AccountStatus`, schema: accountSchema })
|
|
94
96
|
|
|
95
97
|
export const beginResult = Object.freeze({
|
|
96
|
-
mode: 'strict', typeSymbol: `${pkg}#BeginResult`,
|
|
98
|
+
mode: 'strict', create() { return this.schema }, typeSymbol: `${pkg}#BeginResult`,
|
|
97
99
|
schema: z.union([
|
|
98
100
|
z.object({ mode: z.literal('redirect'), authorizationURL: z.string().url() }).strict(),
|
|
99
101
|
z.object({ mode: z.literal('completed'), status: accountSchema }).strict(),
|
|
@@ -102,7 +104,7 @@ export const beginResult = Object.freeze({
|
|
|
102
104
|
})
|
|
103
105
|
|
|
104
106
|
export const loginResult = Object.freeze({
|
|
105
|
-
mode: 'strict', typeSymbol: `${pkg}#LoginResult`,
|
|
107
|
+
mode: 'strict', create() { return this.schema }, typeSymbol: `${pkg}#LoginResult`,
|
|
106
108
|
schema: z.object({
|
|
107
109
|
loginID: loginIdSchema, profileID: profileIdSchema, expiresAt: z.string().datetime(),
|
|
108
110
|
state: z.enum(['pending', 'completed', 'cancelled', 'expired', 'failed']),
|
|
@@ -111,5 +113,5 @@ export const loginResult = Object.freeze({
|
|
|
111
113
|
})
|
|
112
114
|
|
|
113
115
|
export function jsonParameter(name, schema, typeSymbol) {
|
|
114
|
-
return Object.freeze({ name, wire: name, source: 'json', codec: Object.freeze({ mode: 'strict', typeSymbol, schema }) })
|
|
116
|
+
return Object.freeze({ name, wire: name, source: 'json', codec: Object.freeze({ mode: 'strict', create() { return this.schema }, typeSymbol, schema }) })
|
|
115
117
|
}
|