Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"dependencies": {
"@element-plus/icons-vue": "^2.1.0",
"@fontsource/roboto": "^5.0.0",
"@grpc/grpc-js": "^1.14.3",
"@grpc/grpc-js": "^1.14.4",
"@highlightjs/vue-plugin": "^2.1.0",
"@types/node-fetch": "^2.6.12",
"ai": "^4.1.43",
Expand Down Expand Up @@ -72,6 +72,7 @@
"@ai-sdk/vue": "^1.1.18",
"@playwright/test": "^1.51.1",
"@rushstack/eslint-patch": "^1.4.0",
"@secretlint/secretlint-rule-preset-recommend": "^9.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@testing-library/vue": "^8.1.0",
"@types/jest": "^29.5.14",
Expand All @@ -90,15 +91,14 @@
"eslint": "^9.15.0",
"eslint-plugin-security": "^3.0.1",
"eslint-plugin-vue": "^9.12.0",
"secretlint": "^9.0.0",
"@secretlint/secretlint-rule-preset-recommend": "^9.0.0",
"happy-dom": "^17.1.4",
"jest": "^29.7.0",
"jsdom": "^25.0.1",
"node-mocks-http": "^1.16.2",
"npm-run-all2": "^7.0.1",
"playwright": "^1.51.1",
"prettier": "^3.0.1",
"secretlint": "^9.0.0",
"superagent": "^9.0.0",
"supertest": "^7.0.0",
"svelte": "^5.45.3",
Expand Down
5 changes: 5 additions & 0 deletions server/routes/oauth2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,9 @@ router.get('/oauth2/callback', async (req: Request, res: Response) => {
}

// Also store clientConfig for OBP API calls.
// baseUri/version are required by services that read session.clientConfig directly
// (e.g. OBPConsentsService for the Opey consent flow) — without baseUri the consent
// request builds an "undefined/obp/..." URL and throws "Invalid URL".
// OBP-API validates the Bearer value as a JWT (issuer-based dispatch in OAuth2Login),
// but some providers (e.g. Google) issue opaque access tokens (ya29...). For those,
// send the id_token to OBP instead — OBP's Google branch expects it (applyIdTokenRules).
Expand All @@ -265,6 +268,8 @@ router.get('/oauth2/callback', async (req: Request, res: Response) => {
)
}
session.clientConfig = {
baseUri: process.env.VITE_OBP_API_HOST,
version: process.env.VITE_OBP_API_VERSION,
oauth2: {
accessToken: obpAccessToken,
tokenType: 'Bearer'
Expand Down
8 changes: 8 additions & 0 deletions server/routes/opey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,10 @@ router.post('/opey/consent', async (req: Request, res: Response) => {
// If we have a consent id, we can get the consent from OBP
const consent = await obpConsentsService.getConsentByConsentId(session, consentId)

// Establish (or confirm) the Opey session now, so a consent that OBP accepts but
// Opey rejects surfaces here instead of failing silently on the first chat message.
await opeyClientService.establishSession(session.opeyConfig)

return res.status(200).json({ consent_id: consent.consent_id, jwt: consent.jwt })
} else {
console.log('No existing consent ID found')
Expand All @@ -265,6 +269,10 @@ router.post('/opey/consent', async (req: Request, res: Response) => {

console.log('Consent at controller: ', session.opeyConfig)

// Establish the Opey session now (see comment above) rather than deferring it to
// the first stream()/invoke() call.
await opeyClientService.establishSession(session.opeyConfig)

const authConfig = session.opeyConfig?.authConfig

res.status(200).json({
Expand Down
3 changes: 3 additions & 0 deletions server/schema/OpeySchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export interface OpeyConfig {
baseUri: string,
paths: OpeyPaths,
authConfig?: AuthConfig,
// Opey session cookie ("session=..."), established once via /create-session and
// reused for subsequent /stream and /invoke calls in the same conversation.
sessionCookie?: string,
}

export interface ConsentRequestResponse {
Expand Down
90 changes: 83 additions & 7 deletions server/services/OpeyClientService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,13 @@ export default class OpeyClientService {
};
}
}


// Merge the Opey session cookie if provided, so a session established on an
// earlier call is picked up instead of creating a new one every time.
if (partialConfig.sessionCookie) {
mergedConfig.sessionCookie = partialConfig.sessionCookie;
}

return mergedConfig;
}

Expand Down Expand Up @@ -131,7 +137,10 @@ export default class OpeyClientService {
// Get auth headers
const authHeaders = await this.getConsentAuthHeaders(config)


// Opey's /stream requires an authenticated session. Reuse one already
// established (e.g. by /opey/consent) or create one now, then forward it.
const sessionCookie = await this.getOrCreateSessionCookie(config, opeyConfig)

try {

const url = `${config.baseUri}${config.paths.stream}`
Expand All @@ -140,10 +149,10 @@ export default class OpeyClientService {
stream_input.stream_tokens = true

console.log(`Posting to Opey with streaming: ${JSON.stringify(stream_input)}\n URL: ${url}`) //DEBUG

const response = await fetch(url, {
method: 'POST',
headers: authHeaders,
headers: { ...authHeaders, 'Cookie': sessionCookie },
body: JSON.stringify(stream_input)
})
if (!response.body) {
Expand Down Expand Up @@ -185,15 +194,19 @@ export default class OpeyClientService {

// Get auth headers
const authHeaders = await this.getConsentAuthHeaders(config)


// Opey's /invoke requires the same authenticated session as /stream (see
// createOpeySession's doc comment) — reuse one already established or create it.
const sessionCookie = await this.getOrCreateSessionCookie(config, opeyConfig)

const url = `${config.baseUri}${config.paths.invoke}`

console.log(`Posting to Opey, STREAMING OFF: ${JSON.stringify(user_input)}\n URL: ${url}`) //DEBUG

try {
const response = await fetch(url, {
method: 'POST',
headers: authHeaders,
headers: { ...authHeaders, 'Cookie': sessionCookie },
body: JSON.stringify(user_input)
})
if (response.status === 200) {
Expand Down Expand Up @@ -242,7 +255,7 @@ export default class OpeyClientService {
}

async getConsentAuthHeaders(opeyConfig: OpeyConfig): Promise<{ [key: string]: string } | undefined> {

if (!opeyConfig.authConfig || !opeyConfig.authConfig.obpConsent) {
throw new Error('AuthConfig not found or obpConsent missing')
}
Expand All @@ -251,4 +264,67 @@ export default class OpeyClientService {
'Content-Type': 'application/json'
}
}

/**
* Establish an authenticated Opey session using the OBP Consent-JWT and return
* the session cookie ("session=...") to forward on subsequent /stream or /invoke
* calls. Opey's chat endpoints require a session (created via /create-session);
* driving it server-side keeps the browser same-origin (mirrors the Portal design).
*/
/**
* Returns an Opey session cookie, reusing `config.sessionCookie` if one was already
* established, otherwise creating one via createOpeySession(). A freshly created cookie
* is written onto `persistTo` (the caller's own config object, e.g. an Express session's
* opeyConfig) so later calls in the same conversation reuse it instead of establishing a
* new session per message.
*/
async getOrCreateSessionCookie(config: OpeyConfig, persistTo?: Partial<OpeyConfig>): Promise<string> {
if (config.sessionCookie) {
return config.sessionCookie
}
const sessionCookie = await this.createOpeySession(config)
if (persistTo) {
persistTo.sessionCookie = sessionCookie
}
return sessionCookie
}

/**
* Validates the auth config and eagerly establishes (or reuses) the Opey session,
* persisting the cookie onto `opeyConfig`. Intended to be called right after consent
* is granted so a broken/rejected consent surfaces immediately instead of only on the
* user's first chat message.
*/
async establishSession(opeyConfig: Partial<OpeyConfig>): Promise<string> {
const config = await this.getOpeyConfig(opeyConfig)
const auth = await this.checkAuthConfig(config)
if (!auth.valid) {
throw new Error(`AuthConfig not valid: ${auth.reason}`)
}
return await this.getOrCreateSessionCookie(config, opeyConfig)
}

async createOpeySession(opeyConfig: OpeyConfig): Promise<string> {
const authHeaders = await this.getConsentAuthHeaders(opeyConfig)
const url = `${opeyConfig.baseUri}/create-session`
const response = await fetch(url, {
method: 'POST',
headers: authHeaders,
body: JSON.stringify({})
})
if (!response.ok) {
throw new Error(`Failed to create Opey session: ${response.status} ${response.statusText}`)
}
// undici exposes getSetCookie(); fall back to the combined header otherwise
const setCookies: string[] =
(response.headers as any).getSetCookie?.() ??
((response.headers.get('set-cookie') ? [response.headers.get('set-cookie') as string] : []))
const sessionCookie = setCookies
.map(c => c.split(';')[0])
.find(c => c.startsWith('session='))
if (!sessionCookie) {
throw new Error('Opey did not return a session cookie from /create-session')
}
return sessionCookie
}
}
33 changes: 17 additions & 16 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,21 +306,23 @@ async function setupData(app: App<Element>, worker: Worker) {
}
}

const { resourceDocs, groupedDocs } = await cacheResourceDocs(
cacheStorageOfResourceDocs,
cachedResponseOfResourceDocs,
worker
)
const messageDocs = await cacheMessageDocs(
cacheStorageOfMessageDocs,
cachedResponseOfMessageDocs,
worker
)
const messageDocsJsonSchema = await cacheMessageDocsJsonSchema(
cacheStorageOfMessageDocsJsonSchema,
cachedResponseOfMessageDocsJsonSchema,
worker
)
// These four startup tasks are mutually independent, so fetch them concurrently.
// Promise.all keeps the existing fail-fast behavior: any one failing throws to the error page.
const [
{ resourceDocs, groupedDocs },
messageDocs,
messageDocsJsonSchema,
glossary
] = await Promise.all([
cacheResourceDocs(cacheStorageOfResourceDocs, cachedResponseOfResourceDocs, worker),
cacheMessageDocs(cacheStorageOfMessageDocs, cachedResponseOfMessageDocs, worker),
cacheMessageDocsJsonSchema(
cacheStorageOfMessageDocsJsonSchema,
cachedResponseOfMessageDocsJsonSchema,
worker
),
getOBPGlossary()
])

// Provide data to a component's descendants
// App-level provides are available to all components rendered in the app
Expand All @@ -331,7 +333,6 @@ async function setupData(app: App<Element>, worker: Worker) {
app.provide(obpGroupedMessageDocsKey, messageDocs)
app.provide(obpGroupedMessageDocsJsonSchemaKey, messageDocsJsonSchema)
app.provide(obpApiHostKey, import.meta.env.VITE_OBP_API_HOST)
const glossary = await getOBPGlossary()
app.provide(obpGlossaryKey, glossary)

// Try to load user's API collections (requires authentication)
Expand Down
31 changes: 31 additions & 0 deletions src/obp/common-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,37 @@ export async function answerobpConsentChallenge(answerBody: any) {
return response
}

// Runs `task` over `items` with at most `limit` calls in flight at once.
// One task rejecting does not stop the others: every item is still attempted (the
// worker keeps draining the queue), and the first error is only re-thrown after all
// items have settled, so partial results already written by other tasks are kept.
export async function runWithConcurrency<T>(
items: readonly T[],
limit: number,
task: (item: T) => Promise<void>
): Promise<void> {
const queue = [...items]
let firstError: unknown
let hasError = false
const workers = Array.from({ length: Math.min(limit, queue.length) }, async () => {
while (queue.length > 0) {
const item = queue.shift() as T
try {
await task(item)
} catch (error) {
if (!hasError) {
hasError = true
firstError = error
}
}
}
})
await Promise.all(workers)
if (hasError) {
throw firstError
}
}

export function clearCacheByName(cacheName: string) {
if ('caches' in window) {
caches.delete(cacheName).then(function(success) {
Expand Down
Loading