diff --git a/src/app/models/assistant.ts b/src/app/models/assistant.ts index 1622875..234d024 100644 --- a/src/app/models/assistant.ts +++ b/src/app/models/assistant.ts @@ -57,9 +57,34 @@ export type AssistantPhaseModels = { export type AssistantLlmSelection = { provider: string; model: string; + credentialId?: string; phaseModels?: AssistantPhaseModels; }; +export type VaultSecret = { + id: string; + label: string; + provider: string; + description?: string; + active: boolean; + lastUsedAt?: string; + maskedPreview?: string; +}; + +export type VaultSecretCreateRequest = { + label: string; + provider: string; + description?: string; + value: string; +}; + +export type VaultSecretUpdateRequest = { + label?: string; + description?: string; + active?: boolean; + value?: string; +}; + export type AssistantSessionRequest = { llmSelection?: AssistantLlmSelection; }; diff --git a/src/app/models/llm-provider.ts b/src/app/models/llm-provider.ts new file mode 100644 index 0000000..67eeebf --- /dev/null +++ b/src/app/models/llm-provider.ts @@ -0,0 +1,11 @@ +export type LlmProviderCapability = { + name: string; + requiresCredential: boolean; +}; + +export type ExecutionVaultCredential = { + id: string; + label: string; + description?: string; + provider: string; +}; diff --git a/src/app/services/llm-provider/execution-vault-credentials-call.base.ts b/src/app/services/llm-provider/execution-vault-credentials-call.base.ts new file mode 100644 index 0000000..f93f106 --- /dev/null +++ b/src/app/services/llm-provider/execution-vault-credentials-call.base.ts @@ -0,0 +1,6 @@ +import { ExecutionVaultCredential } from '@models/llm-provider'; +import { Observable } from 'rxjs'; + +export abstract class ExecutionVaultCredentialsCallServiceBase { + abstract listForProvider(provider: string): Observable; +} diff --git a/src/app/services/llm-provider/execution-vault-credentials-call.fake.ts b/src/app/services/llm-provider/execution-vault-credentials-call.fake.ts new file mode 100644 index 0000000..7f7717f --- /dev/null +++ b/src/app/services/llm-provider/execution-vault-credentials-call.fake.ts @@ -0,0 +1,15 @@ +import { ExecutionVaultCredential } from '@models/llm-provider'; +import { Observable, of } from 'rxjs'; +import { vaultFakeStore } from '@services/vault/vault-fake-store'; +import { ExecutionVaultCredentialsCallServiceBase } from './execution-vault-credentials-call.base'; + +export class ExecutionVaultCredentialsCallServiceFake extends ExecutionVaultCredentialsCallServiceBase { + override listForProvider(provider: string): Observable { + return of(vaultFakeStore.listActiveByProvider(provider).map((secret) => ({ + id: secret.id, + label: secret.label, + description: secret.description, + provider: secret.provider + }))); + } +} diff --git a/src/app/services/llm-provider/execution-vault-credentials-call.spec.ts b/src/app/services/llm-provider/execution-vault-credentials-call.spec.ts new file mode 100644 index 0000000..5b2c04d --- /dev/null +++ b/src/app/services/llm-provider/execution-vault-credentials-call.spec.ts @@ -0,0 +1,49 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { environment } from '@environment'; +import { firstValueFrom } from 'rxjs'; +import { ExecutionVaultCredentialsCallService } from './execution-vault-credentials-call'; + +describe('ExecutionVaultCredentialsCallService', () => { + let service: ExecutionVaultCredentialsCallService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ExecutionVaultCredentialsCallService, provideHttpClient(), provideHttpClientTesting()] + }); + service = TestBed.inject(ExecutionVaultCredentialsCallService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('requests the credentials of a single provider and maps the vault secret id', async () => { + const result = firstValueFrom(service.listForProvider('OpenAI')); + const request = httpMock.expectOne( + (candidate) => candidate.url === `${environment.apiUrl}/secure-retriever/UserSecrets/forProvider/items` + ); + + expect(request.request.params.get('provider')).toBe('OpenAI'); + request.flush({ + items: [ + { + data: 'vault-secret-1', + descriptor: { label: 'OpenAI key', description: 'shared', meta: { provider: 'OpenAI' } } + }, + { + data: '', + descriptor: { label: 'Broken entry', meta: { provider: 'OpenAI' } } + } + ] + }); + + await expect(result).resolves.toEqual([{ + id: 'vault-secret-1', + label: 'OpenAI key', + description: 'shared', + provider: 'OpenAI' + }]); + }); +}); diff --git a/src/app/services/llm-provider/execution-vault-credentials-call.ts b/src/app/services/llm-provider/execution-vault-credentials-call.ts new file mode 100644 index 0000000..ea60b78 --- /dev/null +++ b/src/app/services/llm-provider/execution-vault-credentials-call.ts @@ -0,0 +1,43 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { environment } from '@environment'; +import { ExecutionVaultCredential } from '@models/llm-provider'; +import { map, Observable } from 'rxjs'; +import { ExecutionVaultCredentialsCallServiceBase } from './execution-vault-credentials-call.base'; + +export class ExecutionVaultCredentialsCallService extends ExecutionVaultCredentialsCallServiceBase { + private readonly http = inject(HttpClient); + + override listForProvider(provider: string): Observable { + const params = new HttpParams().set('provider', provider); + return this.http.get( + `${environment.apiUrl}/secure-retriever/UserSecrets/forProvider/items`, + { params } + ).pipe(map((raw) => normalizeCredentials(raw))); + } +} + +function normalizeCredentials(raw: unknown): ExecutionVaultCredential[] { + const items = Array.isArray(raw) + ? raw + : raw && typeof raw === 'object' + ? ((raw as Record)['items'] ?? (raw as Record)['values'] ?? []) + : []; + if (!Array.isArray(items)) return []; + + return items.map((item) => { + const value = item && typeof item === 'object' ? item as Record : {}; + const descriptor = value['descriptor'] && typeof value['descriptor'] === 'object' + ? value['descriptor'] as Record + : {}; + const meta = descriptor['meta'] && typeof descriptor['meta'] === 'object' + ? descriptor['meta'] as Record + : {}; + return { + id: String(value['data'] ?? ''), + label: String(descriptor['label'] ?? 'Credential'), + description: typeof descriptor['description'] === 'string' ? descriptor['description'] : undefined, + provider: String(meta['provider'] ?? '') + }; + }).filter((item) => item.id.length > 0); +} diff --git a/src/app/services/llm-provider/execution-vault-credentials.ts b/src/app/services/llm-provider/execution-vault-credentials.ts new file mode 100644 index 0000000..8ced4a1 --- /dev/null +++ b/src/app/services/llm-provider/execution-vault-credentials.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@angular/core'; +import { environment } from '@environment'; +import { ExecutionVaultCredential } from '@models/llm-provider'; +import { Observable } from 'rxjs'; +import { ExecutionVaultCredentialsCallServiceBase } from './execution-vault-credentials-call.base'; + +@Injectable({ providedIn: 'root' }) +export class ExecutionVaultCredentialsService { + executionVaultCredentialsCallService: ExecutionVaultCredentialsCallServiceBase = + new environment.executionVaultCredentialsCallService(); + + listForProvider(provider: string): Observable { + return this.executionVaultCredentialsCallService.listForProvider(provider); + } +} diff --git a/src/app/services/llm-provider/llm-provider-call.base.ts b/src/app/services/llm-provider/llm-provider-call.base.ts new file mode 100644 index 0000000..3142acc --- /dev/null +++ b/src/app/services/llm-provider/llm-provider-call.base.ts @@ -0,0 +1,6 @@ +import { LlmProviderCapability } from '@models/llm-provider'; +import { Observable } from 'rxjs'; + +export abstract class LlmProviderCallServiceBase { + abstract listCapabilities(): Observable; +} diff --git a/src/app/services/llm-provider/llm-provider-call.fake.ts b/src/app/services/llm-provider/llm-provider-call.fake.ts new file mode 100644 index 0000000..3e3fff0 --- /dev/null +++ b/src/app/services/llm-provider/llm-provider-call.fake.ts @@ -0,0 +1,13 @@ +import { LlmProviderCapability } from '@models/llm-provider'; +import { Observable, of } from 'rxjs'; +import { LlmProviderCallServiceBase } from './llm-provider-call.base'; + +export class LlmProviderCallServiceFake extends LlmProviderCallServiceBase { + override listCapabilities(): Observable { + return of([ + { name: 'InternalOllama', requiresCredential: false }, + { name: 'testProvider', requiresCredential: true }, + { name: 'Gemini', requiresCredential: true } + ]); + } +} diff --git a/src/app/services/llm-provider/llm-provider-call.spec.ts b/src/app/services/llm-provider/llm-provider-call.spec.ts new file mode 100644 index 0000000..da7519b --- /dev/null +++ b/src/app/services/llm-provider/llm-provider-call.spec.ts @@ -0,0 +1,33 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { environment } from '@environment'; +import { firstValueFrom } from 'rxjs'; +import { LlmProviderCallService } from './llm-provider-call'; + +describe('LlmProviderCallService', () => { + let service: LlmProviderCallService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [LlmProviderCallService, provideHttpClient(), provideHttpClientTesting()] + }); + service = TestBed.inject(LlmProviderCallService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('maps provider capabilities from the backend', async () => { + const result = firstValueFrom(service.listCapabilities()); + httpMock.expectOne(`${environment.apiUrl}/llm/providers`).flush([ + { name: 'InternalOllama', requiresCredential: false }, + { name: 'OpenAI', requiresCredential: true } + ]); + await expect(result).resolves.toEqual([ + { name: 'InternalOllama', requiresCredential: false }, + { name: 'OpenAI', requiresCredential: true } + ]); + }); +}); diff --git a/src/app/services/llm-provider/llm-provider-call.ts b/src/app/services/llm-provider/llm-provider-call.ts new file mode 100644 index 0000000..7968774 --- /dev/null +++ b/src/app/services/llm-provider/llm-provider-call.ts @@ -0,0 +1,25 @@ +import { HttpClient } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { environment } from '@environment'; +import { LlmProviderCapability } from '@models/llm-provider'; +import { map, Observable } from 'rxjs'; +import { LlmProviderCallServiceBase } from './llm-provider-call.base'; + +export class LlmProviderCallService extends LlmProviderCallServiceBase { + private readonly http = inject(HttpClient); + + override listCapabilities(): Observable { + return this.http.get(`${environment.apiUrl}/llm/providers`).pipe( + map((raw) => { + if (!Array.isArray(raw)) return []; + return raw + .filter((item): item is Record => !!item && typeof item === 'object') + .map((item) => ({ + name: String(item['name'] ?? '').trim(), + requiresCredential: item['requiresCredential'] === true + })) + .filter((item) => item.name.length > 0); + }) + ); + } +} diff --git a/src/app/services/llm-provider/llm-provider.ts b/src/app/services/llm-provider/llm-provider.ts new file mode 100644 index 0000000..1410b90 --- /dev/null +++ b/src/app/services/llm-provider/llm-provider.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@angular/core'; +import { environment } from '@environment'; +import { LlmProviderCapability } from '@models/llm-provider'; +import { Observable } from 'rxjs'; +import { LlmProviderCallServiceBase } from './llm-provider-call.base'; + +@Injectable({ providedIn: 'root' }) +export class LlmProviderService { + llmProviderCallService: LlmProviderCallServiceBase = new environment.llmProviderCallService(); + + listCapabilities(): Observable { + return this.llmProviderCallService.listCapabilities(); + } +} diff --git a/src/app/services/shared/http-error.util.spec.ts b/src/app/services/shared/http-error.util.spec.ts index 048888b..85dcbb5 100644 --- a/src/app/services/shared/http-error.util.spec.ts +++ b/src/app/services/shared/http-error.util.spec.ts @@ -12,6 +12,7 @@ describe('extractHttpErrorMessage', () => { expect(extractHttpErrorMessage(new HttpErrorResponse({ error: { message: 'msg' }, status: 400 }))).toBe('msg'); expect(extractHttpErrorMessage(new HttpErrorResponse({ error: { error: 'err' }, status: 400 }))).toBe('err'); expect(extractHttpErrorMessage(new HttpErrorResponse({ error: { details: 'det' }, status: 400 }))).toBe('det'); + expect(extractHttpErrorMessage(new HttpErrorResponse({ error: { detail: 'problem detail' }, status: 400 }))).toBe('problem detail'); }); it('returns null when nothing usable is present', () => { diff --git a/src/app/services/shared/http-error.util.ts b/src/app/services/shared/http-error.util.ts index 0030ea2..0d6017e 100644 --- a/src/app/services/shared/http-error.util.ts +++ b/src/app/services/shared/http-error.util.ts @@ -3,7 +3,7 @@ import { Observable, throwError } from 'rxjs'; /** * Reads a human-readable message out of a backend error body, trying the - * conventional `message`/`error`/`details` string fields in that order. + * conventional `message`/`error`/`details`/`detail` string fields in that order. */ export function extractHttpErrorMessage(error: HttpErrorResponse): string | null { const payload = error.error; @@ -24,6 +24,11 @@ export function extractHttpErrorMessage(error: HttpErrorResponse): string | null if (typeof details === 'string' && details.trim().length > 0) { return details.trim(); } + // RFC 7807 ProblemDetail, which the execution endpoints answer with. + const detail = record['detail']; + if (typeof detail === 'string' && detail.trim().length > 0) { + return detail.trim(); + } } return null; } diff --git a/src/app/services/vault/credential-error-messages.ts b/src/app/services/vault/credential-error-messages.ts new file mode 100644 index 0000000..559eeb0 --- /dev/null +++ b/src/app/services/vault/credential-error-messages.ts @@ -0,0 +1,9 @@ +/** + * Copy shared by every caller that talks to a credential endpoint, so the vault + * and the assistant report the same statuses the same way. + */ +export const CREDENTIAL_ERROR_MESSAGES: Record = { + 400: 'The credential is invalid, inactive, or not compatible with the selected provider.', + 401: 'You must sign in to use or manage credentials.', + 409: 'This provider does not support user credentials.' +}; diff --git a/src/app/services/vault/vault-call.base.ts b/src/app/services/vault/vault-call.base.ts new file mode 100644 index 0000000..a613e92 --- /dev/null +++ b/src/app/services/vault/vault-call.base.ts @@ -0,0 +1,31 @@ +import { VaultSecret, VaultSecretCreateRequest, VaultSecretUpdateRequest } from '@models/assistant'; +import { Observable } from 'rxjs'; + +export abstract class VaultCallServiceBase { + abstract listSecrets(): Observable; + abstract createSecret(request: VaultSecretCreateRequest): Observable; + abstract updateSecret(id: string, request: VaultSecretUpdateRequest): Observable; + abstract deleteSecret(id: string): Observable; +} + +export function mapVaultSecrets(raw: unknown): VaultSecret[] { + const items = Array.isArray(raw) + ? raw + : raw && typeof raw === 'object' + ? ((raw as Record)['items'] ?? (raw as Record)['values'] ?? []) + : []; + return Array.isArray(items) ? items.map(mapVaultSecret).filter((item) => !!item.id) : []; +} + +export function mapVaultSecret(raw: unknown): VaultSecret { + const value = (raw && typeof raw === 'object' ? raw : {}) as Record; + return { + id: String(value['id'] ?? value['secretId'] ?? ''), + label: String(value['label'] ?? ''), + provider: String(value['provider'] ?? ''), + description: typeof value['description'] === 'string' ? value['description'] : undefined, + active: value['active'] !== false && value['enabled'] !== false, + lastUsedAt: typeof value['lastUsedAt'] === 'string' ? value['lastUsedAt'] : undefined, + maskedPreview: typeof value['maskedPreview'] === 'string' ? value['maskedPreview'] : undefined + }; +} diff --git a/src/app/services/vault/vault-call.fake.ts b/src/app/services/vault/vault-call.fake.ts new file mode 100644 index 0000000..7b8f386 --- /dev/null +++ b/src/app/services/vault/vault-call.fake.ts @@ -0,0 +1,28 @@ +import { VaultSecret, VaultSecretCreateRequest, VaultSecretUpdateRequest } from '@models/assistant'; +import { Observable, of, throwError } from 'rxjs'; +import { VaultCallServiceBase } from './vault-call.base'; +import { vaultFakeStore } from './vault-fake-store'; + +export class VaultCallServiceFake extends VaultCallServiceBase { + override listSecrets(): Observable { + return of(vaultFakeStore.list()); + } + + override createSecret(request: VaultSecretCreateRequest): Observable { + if (!request.value.trim()) return throwError(() => new Error('The credential value is required.')); + return of(vaultFakeStore.create(request)); + } + + override updateSecret(id: string, request: VaultSecretUpdateRequest): Observable { + try { + return of(vaultFakeStore.update(id, request)); + } catch (error) { + return throwError(() => error); + } + } + + override deleteSecret(id: string): Observable { + vaultFakeStore.remove(id); + return of(void 0); + } +} diff --git a/src/app/services/vault/vault-call.spec.ts b/src/app/services/vault/vault-call.spec.ts new file mode 100644 index 0000000..8c62c87 --- /dev/null +++ b/src/app/services/vault/vault-call.spec.ts @@ -0,0 +1,66 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { environment } from '@environment'; +import { firstValueFrom } from 'rxjs'; +import { CREDENTIAL_ERROR_MESSAGES } from './credential-error-messages'; +import { VaultCallService } from './vault-call'; + +describe('VaultCallService', () => { + let service: VaultCallService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [VaultCallService, provideHttpClient(), provideHttpClientTesting()] + }); + service = TestBed.inject(VaultCallService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('maps secret metadata without exposing a value', async () => { + const result = firstValueFrom(service.listSecrets()); + httpMock.expectOne(`${environment.apiUrl}/vault/secrets`).flush([{ + id: 'credential-1', + label: 'OpenAI key', + provider: 'OpenAI', + active: true, + maskedPreview: '***', + value: 'must-not-reach-the-ui' + }]); + + await expect(result).resolves.toEqual([{ + id: 'credential-1', + label: 'OpenAI key', + provider: 'OpenAI', + active: true, + maskedPreview: '***' + }]); + }); + + it('sends a value only on explicit create or rotation requests', async () => { + const create = firstValueFrom(service.createSecret({ + label: 'OpenAI key', provider: 'OpenAI', value: 'secret-value' + })); + const createRequest = httpMock.expectOne(`${environment.apiUrl}/vault/secrets`); + expect(createRequest.request.body).toEqual({ label: 'OpenAI key', provider: 'OpenAI', value: 'secret-value' }); + createRequest.flush({ id: 'credential-1', label: 'OpenAI key', provider: 'OpenAI', active: true }); + await create; + + const update = firstValueFrom(service.updateSecret('credential-1', { value: 'rotated-value' })); + const updateRequest = httpMock.expectOne(`${environment.apiUrl}/vault/secrets/credential-1`); + expect(updateRequest.request.body).toEqual({ value: 'rotated-value' }); + updateRequest.flush({ id: 'credential-1', label: 'OpenAI key', provider: 'OpenAI', active: true }); + await update; + }); + + it('falls back to the shared credential message when the backend sends no body', async () => { + const result = firstValueFrom(service.deleteSecret('credential-1')); + httpMock.expectOne(`${environment.apiUrl}/vault/secrets/credential-1`) + .flush(null, { status: 409, statusText: 'Conflict' }); + + await expect(result).rejects.toThrow(CREDENTIAL_ERROR_MESSAGES[409]); + }); +}); diff --git a/src/app/services/vault/vault-call.ts b/src/app/services/vault/vault-call.ts new file mode 100644 index 0000000..f08d743 --- /dev/null +++ b/src/app/services/vault/vault-call.ts @@ -0,0 +1,47 @@ +import { HttpClient } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { environment } from '@environment'; +import { VaultSecret, VaultSecretCreateRequest, VaultSecretUpdateRequest } from '@models/assistant'; +import { extractHttpErrorMessage } from '@services/shared/http-error.util'; +import { catchError, map, Observable, throwError } from 'rxjs'; +import { CREDENTIAL_ERROR_MESSAGES } from './credential-error-messages'; +import { mapVaultSecret, mapVaultSecrets, VaultCallServiceBase } from './vault-call.base'; + +export class VaultCallService extends VaultCallServiceBase { + private readonly http = inject(HttpClient); + + override listSecrets(): Observable { + return this.http.get(`${environment.apiUrl}/vault/secrets`).pipe( + map((raw) => mapVaultSecrets(raw)), + catchError((error: unknown) => this.vaultError(error)) + ); + } + + override createSecret(request: VaultSecretCreateRequest): Observable { + return this.http.post(`${environment.apiUrl}/vault/secrets`, request).pipe( + map((raw) => mapVaultSecret(raw)), + catchError((error: unknown) => this.vaultError(error)) + ); + } + + override updateSecret(id: string, request: VaultSecretUpdateRequest): Observable { + return this.http.put(`${environment.apiUrl}/vault/secrets/${encodeURIComponent(id)}`, request).pipe( + map((raw) => mapVaultSecret(raw)), + catchError((error: unknown) => this.vaultError(error)) + ); + } + + override deleteSecret(id: string): Observable { + return this.http.delete(`${environment.apiUrl}/vault/secrets/${encodeURIComponent(id)}`).pipe( + catchError((error: unknown) => this.vaultError(error)) + ); + } + + private vaultError(error: unknown): Observable { + const response = error as { status?: unknown; error?: unknown }; + const message = extractHttpErrorMessage(response as any) + ?? CREDENTIAL_ERROR_MESSAGES[Number(response?.status)] + ?? 'Unable to update provider credentials.'; + return throwError(() => new Error(message)); + } +} diff --git a/src/app/services/vault/vault-fake-store.spec.ts b/src/app/services/vault/vault-fake-store.spec.ts new file mode 100644 index 0000000..ac1c738 --- /dev/null +++ b/src/app/services/vault/vault-fake-store.spec.ts @@ -0,0 +1,32 @@ +import { ExecutionVaultCredentialsCallServiceFake } from '@services/llm-provider/execution-vault-credentials-call.fake'; +import { firstValueFrom } from 'rxjs'; +import { VaultCallServiceFake } from './vault-call.fake'; +import { vaultFakeStore } from './vault-fake-store'; + +describe('vault fake store', () => { + const vault = new VaultCallServiceFake(); + const credentials = new ExecutionVaultCredentialsCallServiceFake(); + + it('offers only active credentials of the requested provider to an execution', async () => { + const listed = await firstValueFrom(credentials.listForProvider('testProvider')); + + expect(listed.map((item) => item.id)).toEqual([ + 'vault-secret-testprovider-1', + 'vault-secret-testprovider-2' + ]); + expect(vaultFakeStore.list().some((secret) => !secret.active)).toBe(true); + }); + + it('shows a credential created through the vault in the execution picker', async () => { + const created = await firstValueFrom(vault.createSecret({ + label: 'testProvider - fresh key', provider: 'testProvider', value: 'sk-brand-new' + })); + const listed = await firstValueFrom(credentials.listForProvider('testProvider')); + + expect(listed.map((item) => item.id)).toContain(created.id); + + await firstValueFrom(vault.deleteSecret(created.id)); + const afterDelete = await firstValueFrom(credentials.listForProvider('testProvider')); + expect(afterDelete.map((item) => item.id)).not.toContain(created.id); + }); +}); diff --git a/src/app/services/vault/vault-fake-store.ts b/src/app/services/vault/vault-fake-store.ts new file mode 100644 index 0000000..1afbd57 --- /dev/null +++ b/src/app/services/vault/vault-fake-store.ts @@ -0,0 +1,85 @@ +import { VaultSecret, VaultSecretCreateRequest, VaultSecretUpdateRequest } from '@models/assistant'; + +/** + * In-memory vault shared by the fake call services, so a credential created from + * the assistant panel shows up in the execution credential picker, exactly as it + * does against the real backend. + */ +class VaultFakeStore { + private sequence = 0; + private readonly secrets: VaultSecret[] = [ + { + id: 'vault-secret-testprovider-1', + label: 'testProvider - team key', + provider: 'testProvider', + description: 'Shared key for the demo workspace', + active: true, + maskedPreview: 'sk-...4f2a' + }, + { + id: 'vault-secret-testprovider-2', + label: 'testProvider - personal key', + provider: 'testProvider', + active: true, + maskedPreview: 'sk-...91cd' + }, + { + id: 'vault-secret-testprovider-3', + label: 'testProvider - revoked key', + provider: 'testProvider', + description: 'Disabled, never offered to an execution', + active: false, + maskedPreview: 'sk-...0007' + }, + { + id: 'vault-secret-gemini-1', + label: 'Gemini - default key', + provider: 'Gemini', + active: true, + maskedPreview: 'AI...b31' + } + ]; + + list(): VaultSecret[] { + return this.secrets.map((secret) => ({ ...secret })); + } + + /** Mirrors the server-side filter of the credential listing: owner, active, provider. */ + listActiveByProvider(provider: string): VaultSecret[] { + const wanted = provider.trim().toLowerCase(); + return this.secrets + .filter((secret) => secret.active && secret.provider.trim().toLowerCase() === wanted) + .map((secret) => ({ ...secret })); + } + + create(request: VaultSecretCreateRequest): VaultSecret { + const created: VaultSecret = { + id: `vault-secret-created-${++this.sequence}`, + label: request.label, + provider: request.provider, + description: request.description, + active: true, + maskedPreview: `${request.value.slice(0, 2)}...${request.value.slice(-3)}` + }; + this.secrets.push(created); + return { ...created }; + } + + update(id: string, request: VaultSecretUpdateRequest): VaultSecret { + const secret = this.secrets.find((item) => item.id === id); + if (!secret) throw new Error('The credential no longer exists.'); + if (request.label !== undefined) secret.label = request.label; + if (request.description !== undefined) secret.description = request.description; + if (request.active !== undefined) secret.active = request.active; + if (request.value) secret.maskedPreview = `${request.value.slice(0, 2)}...${request.value.slice(-3)}`; + return { ...secret }; + } + + /** The backend soft-deletes, so the secret stays but stops being offered. */ + remove(id: string): void { + const secret = this.secrets.find((item) => item.id === id); + if (secret) secret.active = false; + } +} + +export const vaultFakeStore = new VaultFakeStore(); diff --git a/src/app/services/vault/vault.ts b/src/app/services/vault/vault.ts new file mode 100644 index 0000000..52fdbc5 --- /dev/null +++ b/src/app/services/vault/vault.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; +import { environment } from '@environment'; +import { VaultSecret, VaultSecretCreateRequest, VaultSecretUpdateRequest } from '@models/assistant'; +import { Observable } from 'rxjs'; +import { VaultCallServiceBase } from './vault-call.base'; + +export { CREDENTIAL_ERROR_MESSAGES } from './credential-error-messages'; + +@Injectable({ providedIn: 'root' }) +export class VaultService { + vaultCallService: VaultCallServiceBase = new environment.vaultCallService(); + + listSecrets(): Observable { + return this.vaultCallService.listSecrets(); + } + + createSecret(request: VaultSecretCreateRequest): Observable { + return this.vaultCallService.createSecret(request); + } + + updateSecret(id: string, request: VaultSecretUpdateRequest): Observable { + return this.vaultCallService.updateSecret(id, request); + } + + deleteSecret(id: string): Observable { + return this.vaultCallService.deleteSecret(id); + } +} diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index 551bbc5..e6c44e9 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -3,6 +3,9 @@ import { AssistantCallServiceFake } from "@services/assistant/assistant-call.fak import { AuthorizationCallFakeService } from "@services/authorization/authorization-call.fake"; import { BlocksCallServiceFake } from "@services/blocks/blocks-call.fake"; import { ContainersCallServiceFake } from "@services/containers/containers-call.fake"; +import { ExecutionVaultCredentialsCallServiceFake } from "@services/llm-provider/execution-vault-credentials-call.fake"; +import { LlmProviderCallServiceFake } from "@services/llm-provider/llm-provider-call.fake"; +import { VaultCallServiceFake } from "@services/vault/vault-call.fake"; import { FlowsCallServiceFake } from "@services/flows/flows-call.fake"; import { FieldRetrieverCallServiceFake } from "@services/retriever/field-retriever-call.fake"; import { TaskExecutionsCallServiceFake } from "@services/task-executions/task-executions-call.fake"; @@ -20,5 +23,8 @@ export const environment = { blocksCallService: BlocksCallServiceFake, containersCallService: ContainersCallServiceFake, fieldRetrieverCallService: FieldRetrieverCallServiceFake, - taskExecutionsCallService: TaskExecutionsCallServiceFake + taskExecutionsCallService: TaskExecutionsCallServiceFake, + llmProviderCallService: LlmProviderCallServiceFake, + executionVaultCredentialsCallService: ExecutionVaultCredentialsCallServiceFake, + vaultCallService: VaultCallServiceFake }; diff --git a/src/environments/environment.staging.ts b/src/environments/environment.staging.ts index ebefc40..fe1775a 100644 --- a/src/environments/environment.staging.ts +++ b/src/environments/environment.staging.ts @@ -3,6 +3,9 @@ import { AssistantCallService } from "@services/assistant/assistant-call"; import { AuthorizationCallService } from "@services/authorization/authorization-call"; import { BlocksCallService } from "@services/blocks/blocks-call"; import { ContainersCallService } from "@services/containers/containers-call"; +import { ExecutionVaultCredentialsCallService } from "@services/llm-provider/execution-vault-credentials-call"; +import { LlmProviderCallService } from "@services/llm-provider/llm-provider-call"; +import { VaultCallService } from "@services/vault/vault-call"; import { FlowsCallService } from "@services/flows/flows-call"; import { FieldRetrieverCallService } from "@services/retriever/field-retriever-call"; import { TaskExecutionsCallService } from "@services/task-executions/task-executions-call"; @@ -20,5 +23,8 @@ export const environment = { blocksCallService: BlocksCallService, containersCallService: ContainersCallService, fieldRetrieverCallService: FieldRetrieverCallService, - taskExecutionsCallService: TaskExecutionsCallService + taskExecutionsCallService: TaskExecutionsCallService, + llmProviderCallService: LlmProviderCallService, + executionVaultCredentialsCallService: ExecutionVaultCredentialsCallService, + vaultCallService: VaultCallService }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 1af1474..bfd98a8 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -3,6 +3,9 @@ import { AssistantCallService } from "@services/assistant/assistant-call"; import { AuthorizationCallService } from "@services/authorization/authorization-call"; import { BlocksCallService } from "@services/blocks/blocks-call"; import { ContainersCallService } from "@services/containers/containers-call"; +import { ExecutionVaultCredentialsCallService } from "@services/llm-provider/execution-vault-credentials-call"; +import { LlmProviderCallService } from "@services/llm-provider/llm-provider-call"; +import { VaultCallService } from "@services/vault/vault-call"; import { FlowsCallService } from "@services/flows/flows-call"; import { FieldRetrieverCallService } from "@services/retriever/field-retriever-call"; import { TaskExecutionsCallService } from "@services/task-executions/task-executions-call"; @@ -20,5 +23,8 @@ export const environment = { blocksCallService: BlocksCallService, containersCallService: ContainersCallService, fieldRetrieverCallService: FieldRetrieverCallService, - taskExecutionsCallService: TaskExecutionsCallService + taskExecutionsCallService: TaskExecutionsCallService, + llmProviderCallService: LlmProviderCallService, + executionVaultCredentialsCallService: ExecutionVaultCredentialsCallService, + vaultCallService: VaultCallService };