diff --git a/src/app/models/storage-type.ts b/src/app/models/storage-type.ts new file mode 100644 index 0000000..9f85af8 --- /dev/null +++ b/src/app/models/storage-type.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR +// SPDX-License-Identifier: AGPL-3.0-or-later +// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. + +/** One field of a person's own connection to a storage, as the vault form asks for it. */ +export type StorageCredentialField = { + key: string; + label: string; + description?: string | null; + /** Masked in the form. The whole connection is encrypted either way. */ + secret: boolean; + required: boolean; +}; + +/** A kind of storage a flow can use: an S3-compatible object store, a PostgreSQL database. */ +export type StorageType = { + name: string; + description: string; + /** What a read returns (FILES, TEXTS, JSON, KEYS), the default first. */ + viewShapes: string[]; + /** The kinds of value a write accepts (FILE, TEXT, JSON). */ + writableKinds: string[]; + credentialFields: StorageCredentialField[]; +}; diff --git a/src/app/models/task-execution.ts b/src/app/models/task-execution.ts index c42cba3..daf9a7e 100644 --- a/src/app/models/task-execution.ts +++ b/src/app/models/task-execution.ts @@ -100,6 +100,8 @@ export type TaskExecutionAuthorizationRequirement = { fieldName: string; description: string; requiredBySteps: string[]; + /** Paid with a saved credential's id - an LLM key, a person's own storage connection. */ + vaultBacked?: boolean; }; export type TaskExecutionContext = { diff --git a/src/app/services/storage/storage-types-call.base.ts b/src/app/services/storage/storage-types-call.base.ts new file mode 100644 index 0000000..1176e7a --- /dev/null +++ b/src/app/services/storage/storage-types-call.base.ts @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR +// SPDX-License-Identifier: AGPL-3.0-or-later +// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. + +import { StorageType } from '@models/storage-type'; +import { Observable } from 'rxjs'; + +export abstract class StorageTypesCallServiceBase { + abstract listTypes(): Observable; +} diff --git a/src/app/services/storage/storage-types-call.fake.ts b/src/app/services/storage/storage-types-call.fake.ts new file mode 100644 index 0000000..3ed1964 --- /dev/null +++ b/src/app/services/storage/storage-types-call.fake.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR +// SPDX-License-Identifier: AGPL-3.0-or-later +// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. + +import { StorageType } from '@models/storage-type'; +import { Observable, of } from 'rxjs'; +import { StorageTypesCallServiceBase } from './storage-types-call.base'; + +export class StorageTypesCallServiceFake extends StorageTypesCallServiceBase { + override listTypes(): Observable { + return of([ + { + name: 'S3', + description: 'An S3-compatible object store.', + viewShapes: ['FILES', 'TEXTS', 'JSON', 'KEYS'], + writableKinds: ['FILE', 'TEXT', 'JSON'], + credentialFields: [ + { key: 'endpoint', label: 'Endpoint URL', description: 'e.g. https://minio.example.org', secret: false, required: true }, + { key: 'bucket', label: 'Bucket', secret: false, required: true }, + { key: 'accessKey', label: 'Access key', secret: false, required: true }, + { key: 'secretKey', label: 'Secret key', secret: true, required: true } + ] + } + ]); + } +} diff --git a/src/app/services/storage/storage-types-call.ts b/src/app/services/storage/storage-types-call.ts new file mode 100644 index 0000000..3b72096 --- /dev/null +++ b/src/app/services/storage/storage-types-call.ts @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR +// SPDX-License-Identifier: AGPL-3.0-or-later +// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. + +import { HttpClient } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { environment } from '@environment'; +import { StorageCredentialField, StorageType } from '@models/storage-type'; +import { map, Observable } from 'rxjs'; +import { StorageTypesCallServiceBase } from './storage-types-call.base'; + +export class StorageTypesCallService extends StorageTypesCallServiceBase { + private readonly http = inject(HttpClient); + + override listTypes(): Observable { + return this.http.get(`${environment.apiUrl}/storage/types`).pipe( + map((raw) => (Array.isArray(raw) ? raw : []) + .filter((item): item is Record => !!item && typeof item === 'object') + .map((item) => ({ + name: String(item['name'] ?? '').trim(), + description: String(item['description'] ?? ''), + viewShapes: strings(item['viewShapes']), + writableKinds: strings(item['writableKinds']), + credentialFields: (Array.isArray(item['credentialFields']) ? item['credentialFields'] : []) + .filter((field): field is Record => !!field && typeof field === 'object') + .map((field): StorageCredentialField => ({ + key: String(field['key'] ?? '').trim(), + label: String(field['label'] ?? field['key'] ?? ''), + description: typeof field['description'] === 'string' ? field['description'] : null, + secret: field['secret'] === true, + required: field['required'] === true + })) + .filter((field) => field.key.length > 0) + })) + .filter((type) => type.name.length > 0)) + ); + } +} + +function strings(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; +} diff --git a/src/app/services/storage/storage-types.ts b/src/app/services/storage/storage-types.ts new file mode 100644 index 0000000..5d55e6d --- /dev/null +++ b/src/app/services/storage/storage-types.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR +// SPDX-License-Identifier: AGPL-3.0-or-later +// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. + +import { Injectable } from '@angular/core'; +import { environment } from '@environment'; +import { StorageType } from '@models/storage-type'; +import { Observable, shareReplay } from 'rxjs'; +import { StorageTypesCallServiceBase } from './storage-types-call.base'; + +/** The storage types the server has. They change only with a deploy, so they are fetched once. */ +@Injectable({ providedIn: 'root' }) +export class StorageTypesService { + private readonly call: StorageTypesCallServiceBase = new environment.storageTypesCallService(); + private types$?: Observable; + + listTypes(): Observable { + this.types$ ??= this.call.listTypes().pipe(shareReplay({ bufferSize: 1, refCount: false })); + return this.types$; + } +} diff --git a/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts b/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts index fbff9f0..974e58e 100644 --- a/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts +++ b/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts @@ -199,6 +199,36 @@ describe('authorization gate', () => { expect(isExecutionStartable(provided, gate)).toBe(true); }); + it('asks for a personal storage connection from the vault, not as a value to type in', () => { + const storageRequirement = { + key: 'StorageType::S3::credential', + provider: 'S3', + fieldName: 'credential', + description: 'Select a saved S3 connection.', + requiredBySteps: ['save-note'], + vaultBacked: true + }; + const gate = buildAuthorizationGate(execution({ + requiredAuthorizations: [storageRequirement, headerRequirement], + missingAuthorizationKeys: [storageRequirement.key, headerRequirement.key] + }), readyState); + + expect(gate.vault.map((entry) => entry.provider)).toEqual(['S3']); + expect(gate.runtime.map((requirement) => requirement.key)).toEqual([headerRequirement.key]); + expect(gate.missingProviders).toEqual(['S3']); + }); + + it('trusts the vault flag over the shape of the key', () => { + const flagged = { ...headerRequirement, key: 'Custom::thing', vaultBacked: true }; + const gate = buildAuthorizationGate(execution({ + requiredAuthorizations: [flagged], + missingAuthorizationKeys: [flagged.key] + }), readyState); + + expect(gate.vault.length).toBe(1); + expect(gate.runtime).toEqual([]); + }); + it('keeps a provider key on the vault path even when the catalog is unavailable', () => { const gate = buildAuthorizationGate(execution(), failedState); diff --git a/src/app/shared/task-execution-viewer/execution-viewer.utils.ts b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts index 999e3c5..80e2270 100644 --- a/src/app/shared/task-execution-viewer/execution-viewer.utils.ts +++ b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts @@ -588,10 +588,23 @@ export function authorizationProvider(requirement: TaskExecutionAuthorizationReq return parts.length > 1 ? parts[1].trim() : provider; } +/** Keys of the form `StorageType::::credential`: a person's own storage connection. */ +export const STORAGE_AUTHORIZATION_KEY_PREFIX = 'storagetype::'; + export function isLlmAuthorizationRequirement(requirement: TaskExecutionAuthorizationRequirement): boolean { return String(requirement.key ?? '').trim().toLowerCase().startsWith(LLM_AUTHORIZATION_KEY_PREFIX); } +/** + * Paid with the id of a saved credential rather than a value typed in. The server says so; the key + * prefixes are for a server old enough not to. + */ +export function isVaultAuthorizationRequirement(requirement: TaskExecutionAuthorizationRequirement): boolean { + if (requirement.vaultBacked === true) return true; + const key = String(requirement.key ?? '').trim().toLowerCase(); + return key.startsWith(LLM_AUTHORIZATION_KEY_PREFIX) || key.startsWith(STORAGE_AUTHORIZATION_KEY_PREFIX); +} + export function listAuthorizationRequirements( execution: TaskExecution | null | undefined ): TaskExecutionAuthorizationRequirement[] { @@ -622,7 +635,7 @@ export function buildAuthorizationGate( // A provider key is never payable with a literal value, whatever the catalog says // or fails to say, so it can only ever go down the vault path. - if (isLlmAuthorizationRequirement(requirement)) { + if (isVaultAuthorizationRequirement(requirement)) { const provider = authorizationProvider(requirement); const entry: VaultAuthorizationEntry = { requirement, diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index 5fa6555..df4e99b 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -60,6 +60,8 @@ import { BlocksService } from '@services/blocks/blocks'; import { LlmProviderService } from '@services/llm-provider/llm-provider'; import { ExecutionVaultCredentialsService } from '@services/llm-provider/execution-vault-credentials'; import { VaultService } from '@services/vault/vault'; +import { StorageTypesService } from '@services/storage/storage-types'; +import { StorageType } from '@models/storage-type'; import { extractHttpErrorMessage } from '@services/shared/http-error.util'; import { BiasRerunDialogService, @@ -159,6 +161,7 @@ export class TaskExecutionViewerComponent implements OnDestroy { private llmProviderService = inject(LlmProviderService); private executionVaultCredentials = inject(ExecutionVaultCredentialsService); private vaultService = inject(VaultService); + private storageTypesService = inject(StorageTypesService); private biasRerunDialog = inject(BiasRerunDialogService); private biasCompareDialog = inject(BiasCompareDialogService); private biasComparisonViewState = inject(BiasComparisonViewStateService); @@ -241,6 +244,8 @@ export class TaskExecutionViewerComponent implements OnDestroy { readonly savingAuthorizations = signal>({}); readonly authorizationErrors = signal>({}); readonly llmProviderCapabilities = signal([]); + /** For a person's own storage connection: which fields its form asks for. */ + readonly storageTypes = signal([]); readonly llmProviderCapabilitiesLoading = signal(false); readonly llmProviderCapabilitiesError = signal(null); readonly llmCredentialOptions = signal>({}); @@ -1099,6 +1104,11 @@ export class TaskExecutionViewerComponent implements OnDestroy { */ async openVaultCredentialForm(provider: string) { this.credentialFormError.set(null); + const storageType = this.storageTypeNamed(provider); + if (storageType) { + await this.openStorageConnectionForm(storageType); + return; + } // The provider is fixed by the requirement being answered, so it is shown rather than chosen. const fields: NodeSettingField[] = [ @@ -1141,6 +1151,48 @@ export class TaskExecutionViewerComponent implements OnDestroy { ); } + private storageTypeNamed(provider: string): StorageType | undefined { + const wanted = provider.trim().toLowerCase(); + return this.storageTypes().find((type) => type.name.trim().toLowerCase() === wanted); + } + + /** + * A storage connection is several fields - an endpoint, a bucket, two keys - saved together as + * the JSON of them, encrypted whole. The server works out the address it shows beside it. + */ + private async openStorageConnectionForm(type: StorageType) { + const fieldKey = (key: string) => `connection_${key}`; + const fields: NodeSettingField[] = [ + { key: 'provider', label: 'Storage type', type: 'display' }, + { key: 'label', label: 'Label', type: 'text', required: true, autofocus: true }, + { key: 'description', label: 'Description (optional)', type: 'text' }, + ...type.credentialFields.map((field): NodeSettingField => ({ + key: fieldKey(field.key), + label: field.label, + type: field.secret ? 'password' : 'text', + required: field.required, + tip: field.description ?? undefined + })) + ]; + const initial: Record = { provider: type.name, label: '', description: '' }; + type.credentialFields.forEach((field) => initial[fieldKey(field.key)] = ''); + const result = await this.settingsDialog.open({ title: `Add your ${type.name} connection`, fields, initial }); + if (!result) return; + + const connection: Record = {}; + for (const field of type.credentialFields) { + const value = String(result[fieldKey(field.key)] ?? '').trim(); + if (value) connection[field.key] = value; + } + this.saveExecutionCredential( + type.name, + String(result['label'] ?? '').trim(), + String(result['description'] ?? '').trim(), + JSON.stringify(connection), + '' + ); + } + private providerRequiresEndpoint(provider: string): boolean { const wanted = provider.trim().toLowerCase(); return this.llmProviderCapabilities().find((capability) => @@ -1179,8 +1231,18 @@ export class TaskExecutionViewerComponent implements OnDestroy { }); } + private loadStorageTypes() { + this.storageTypesService.listTypes().pipe(take(1)).subscribe({ + next: (types) => this.storageTypes.set(types), + // Without them an own storage connection falls back to the plain credential form, whose + // save the server then refuses with the reason - worse, but not silent. + error: () => this.storageTypes.set([]) + }); + } + private loadLlmProviderCapabilities() { if (!this.execution()?.id) return; + this.loadStorageTypes(); this.llmProviderCapabilitiesLoading.set(true); this.llmProviderCapabilitiesError.set(null); this.llmProviderService.listCapabilities().pipe(take(1)).subscribe({ diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index 82f518e..70c245c 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. +import { StorageTypesCallServiceFake } from "@services/storage/storage-types-call.fake"; import { AdminCallFakeService } from "@services/admin/admin-call.fake"; import { AssistantCallServiceFake } from "@services/assistant/assistant-call.fake"; import { AuthorizationCallFakeService } from "@services/authorization/authorization-call.fake"; @@ -32,5 +33,6 @@ export const environment = { taskExecutionsCallService: TaskExecutionsCallServiceFake, llmProviderCallService: LlmProviderCallServiceFake, executionVaultCredentialsCallService: ExecutionVaultCredentialsCallServiceFake, - vaultCallService: VaultCallServiceFake + vaultCallService: VaultCallServiceFake, + storageTypesCallService: StorageTypesCallServiceFake }; diff --git a/src/environments/environment.staging.ts b/src/environments/environment.staging.ts index 4e407b9..814046a 100644 --- a/src/environments/environment.staging.ts +++ b/src/environments/environment.staging.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. +import { StorageTypesCallService } from "@services/storage/storage-types-call"; import { AdminCallService } from "@services/admin/admin-call"; import { AssistantCallService } from "@services/assistant/assistant-call"; import { AuthorizationCallService } from "@services/authorization/authorization-call"; @@ -32,5 +33,6 @@ export const environment = { taskExecutionsCallService: TaskExecutionsCallService, llmProviderCallService: LlmProviderCallService, executionVaultCredentialsCallService: ExecutionVaultCredentialsCallService, - vaultCallService: VaultCallService + vaultCallService: VaultCallService, + storageTypesCallService: StorageTypesCallService }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index a66f87c..c5fde04 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. +import { StorageTypesCallService } from "@services/storage/storage-types-call"; import { AdminCallService } from "@services/admin/admin-call"; import { AssistantCallService } from "@services/assistant/assistant-call"; import { AuthorizationCallService } from "@services/authorization/authorization-call"; @@ -32,5 +33,6 @@ export const environment = { taskExecutionsCallService: TaskExecutionsCallService, llmProviderCallService: LlmProviderCallService, executionVaultCredentialsCallService: ExecutionVaultCredentialsCallService, - vaultCallService: VaultCallService + vaultCallService: VaultCallService, + storageTypesCallService: StorageTypesCallService };