diff --git a/README.md b/README.md
index 1c4bc79..c9f0913 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,7 @@ This table is the source of truth for the current UI surface.
| Cloud Explorer / Compute | Yes | Placeholder | Placeholder | AWS EC2 and AMI workflows. |
| Cloud Explorer / Networking | Yes | Placeholder | Placeholder | AWS VPC/networking workflows. |
| Cloud Explorer / Serverless | Yes | Not exposed in navigation | Not exposed in navigation | AWS Lambda flows through the unified shell. |
+| Cloud Explorer / Secrets | No | Yes | Placeholder | Azure Key Vault list/create/delete/inspect through the schema-driven explorer. |
| Dedicated page / Secrets Manager | Yes | No | No | AWS-only page outside Cloud Explorer. |
Visible placeholders in the current sidebar:
@@ -178,9 +179,9 @@ Current gaps:
-Secrets Manager
+Secrets
-This is the only dedicated AWS page still outside Cloud Explorer.
+AWS Secrets Manager remains a dedicated page outside Cloud Explorer.
- List secrets.
- Inspect metadata.
@@ -189,10 +190,18 @@ This is the only dedicated AWS page still outside Cloud Explorer.
- Update values.
- Delete secrets, including force delete.
+Azure Key Vault uses the shared Cloud Explorer:
+
+- List and filter secret metadata without exposing values.
+- Create secrets with optional content type.
+- Inspect normalized metadata.
+- Soft-delete secrets.
+
Current gaps:
-- Not migrated into the Cloud Explorer contract yet.
-- No Azure or GCP secret adapter yet.
+- AWS Secrets Manager is not migrated into the Cloud Explorer contract yet.
+- Key Vault value retrieval, versioning, recovery, and purge workflows are not exposed yet.
+- No GCP Secret Manager adapter yet.
diff --git a/packages/api/src/adapter-azure/AzureKeyVaultAdapter.test.ts b/packages/api/src/adapter-azure/AzureKeyVaultAdapter.test.ts
new file mode 100644
index 0000000..27ffdd0
--- /dev/null
+++ b/packages/api/src/adapter-azure/AzureKeyVaultAdapter.test.ts
@@ -0,0 +1,179 @@
+import {describe, expect, test} from 'bun:test'
+import {AzureKeyVaultAdapter} from './AzureKeyVaultAdapter'
+import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure'
+
+interface RecordedCall {
+ path: string
+ init: RequestInit
+ options: AzureRuntimeFetchOptions
+}
+
+describe('AzureKeyVaultAdapter', () => {
+ test('lists and filters Key Vault secrets without exposing values', async () => {
+ const calls: RecordedCall[] = []
+ const adapter = new AzureKeyVaultAdapter(testClient({
+ '/devstoreaccount1-keyvault/secrets?api-version=7.4': {
+ value: [secretRecord('api-token', 'v2')],
+ nextLink: 'https://devstoreaccount1.vault.azure.net/secrets?api-version=7.4&next=page-2',
+ },
+ '/devstoreaccount1-keyvault/secrets?api-version=7.4&next=page-2': {
+ value: [secretRecord('database-password', 'v1', {env: 'test'})],
+ nextLink: null,
+ },
+ }, calls))
+
+ await expect(adapter.list({search: 'database'})).resolves.toEqual([{
+ id: 'database-password',
+ name: 'database-password',
+ cloud: 'azure',
+ service: 'secrets',
+ type: 'secret',
+ region: null,
+ createdAt: '2026-05-20T20:00:00.000Z',
+ status: 'enabled',
+ version: 'v1',
+ metadata: {
+ provider: 'azure',
+ secretsService: 'key-vault',
+ vaultAccount: 'devstoreaccount1',
+ contentType: 'text/plain',
+ updatedAt: '2026-05-20T20:00:01.000Z',
+ expiresAt: null,
+ notBefore: null,
+ recoveryLevel: 'Purgeable',
+ recoverableDays: 7,
+ tags: [{key: 'env', value: 'test'}],
+ },
+ }])
+ expect(calls.map((call) => call.path)).toEqual([
+ '/devstoreaccount1-keyvault/secrets?api-version=7.4',
+ '/devstoreaccount1-keyvault/secrets?api-version=7.4&next=page-2',
+ ])
+ expect(calls.every((call) => call.options.includeStorageApiVersion === false)).toBe(true)
+ })
+
+ test('gets a secret and omits its value from normalized metadata', async () => {
+ const adapter = new AzureKeyVaultAdapter(testClient({
+ '/devstoreaccount1-keyvault/secrets/database-password?api-version=7.4': {
+ ...secretRecord('database-password', 'v1'),
+ value: 'super-secret',
+ },
+ }))
+
+ const resource = await adapter.get('database-password')
+
+ expect(resource).toMatchObject({id: 'database-password', version: 'v1'})
+ expect(resource?.metadata).not.toHaveProperty('value')
+ })
+
+ test('creates secrets through the Azure Key Vault data-plane contract', async () => {
+ const calls: RecordedCall[] = []
+ const adapter = new AzureKeyVaultAdapter(testClient({
+ '/devstoreaccount1-keyvault/secrets/api-key?api-version=7.4': {
+ ...secretRecord('api-key', 'created-version'),
+ value: 'abc123',
+ contentType: 'application/json',
+ },
+ }, calls))
+
+ const created = await adapter.create({values: {
+ secretName: 'api-key',
+ secretValue: 'abc123',
+ contentType: 'application/json',
+ }})
+
+ expect(created).toMatchObject({id: 'api-key', version: 'created-version'})
+ expect(calls).toHaveLength(1)
+ expect(calls[0].path).toBe('/devstoreaccount1-keyvault/secrets/api-key?api-version=7.4')
+ expect(calls[0].init.method).toBe('PUT')
+ expect(calls[0].init.headers).toMatchObject({
+ authorization: 'Bearer floci-ui',
+ 'content-type': 'application/json',
+ })
+ expect(calls[0].options.includeStorageApiVersion).toBe(false)
+ expect(JSON.parse(String(calls[0].init.body))).toEqual({
+ value: 'abc123',
+ contentType: 'application/json',
+ })
+ })
+
+ test('deletes secrets using soft-delete endpoint', async () => {
+ const calls: RecordedCall[] = []
+ const adapter = new AzureKeyVaultAdapter(testClient({
+ '/devstoreaccount1-keyvault/secrets/api-key?api-version=7.4': {},
+ }, calls))
+
+ await adapter.delete('api-key')
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0].init.method).toBe('DELETE')
+ expect(calls[0].init.headers).toMatchObject({authorization: 'Bearer floci-ui'})
+ expect(calls[0].options.includeStorageApiVersion).toBe(false)
+ })
+
+ test('normalizes missing secrets to null and missing lists to empty', async () => {
+ const adapter = new AzureKeyVaultAdapter(testClient({}))
+
+ await expect(adapter.get('missing')).resolves.toBeNull()
+ await expect(adapter.list()).resolves.toEqual([])
+ })
+
+ test('validates required inputs before calling the runtime', async () => {
+ const calls: RecordedCall[] = []
+ const adapter = new AzureKeyVaultAdapter(testClient({}, calls))
+
+ await expect(adapter.create({values: {secretName: 'not valid', secretValue: 'value'}}))
+ .rejects.toThrow('Use a valid Key Vault secret name')
+ await expect(adapter.create({values: {secretName: 'valid-name', secretValue: ''}}))
+ .rejects.toThrow('secretValue is required')
+ expect(calls).toHaveLength(0)
+ })
+
+ test('exposes Azure Key Vault CRUD capabilities in its schema', () => {
+ const schema = new AzureKeyVaultAdapter(testClient({})).schema()
+
+ expect(schema.service).toBe('secrets')
+ expect(schema.actions).toEqual(['list', 'create', 'delete', 'inspect'])
+ expect(schema.fields.find((field) => field.name === 'secretValue')?.type).toBe('password')
+ const namePattern = schema.fields.find((field) => field.name === 'secretName')?.validation?.pattern
+ expect(() => new RegExp(namePattern ?? '', 'v')).not.toThrow()
+ })
+})
+
+function secretRecord(name: string, version: string, tags: Record = {}) {
+ return {
+ id: `https://devstoreaccount1.vault.azure.net/secrets/${name}/${version}`,
+ attributes: {
+ enabled: true,
+ created: 1779307200,
+ updated: 1779307201,
+ exp: null,
+ nbf: null,
+ recoveryLevel: 'Purgeable',
+ recoverableDays: 7,
+ },
+ contentType: 'text/plain',
+ tags,
+ }
+}
+
+function testClient(
+ responses: Record,
+ calls: RecordedCall[] = [],
+): AzureRuntimeClient {
+ return {
+ endpoint: 'http://localhost:4577',
+ accountName: 'devstoreaccount1',
+ async fetch(path: string, init: RequestInit, options: AzureRuntimeFetchOptions = {}) {
+ calls.push({path, init, options})
+ if (!(path in responses)) {
+ if (options.emptyOnNotFound) return null
+ throw new Error(`Unexpected Key Vault path: ${path}`)
+ }
+ return new Response(JSON.stringify(responses[path]), {
+ status: 200,
+ headers: {'content-type': 'application/json'},
+ })
+ },
+ }
+}
diff --git a/packages/api/src/adapter-azure/AzureKeyVaultAdapter.ts b/packages/api/src/adapter-azure/AzureKeyVaultAdapter.ts
new file mode 100644
index 0000000..b9a6c00
--- /dev/null
+++ b/packages/api/src/adapter-azure/AzureKeyVaultAdapter.ts
@@ -0,0 +1,215 @@
+import {azure, type AzureRuntimeClient} from '../azure'
+import {azureSecretsSchema} from '../cloud-spi/secretsSchema'
+import type {
+ CloudResource,
+ CloudServiceAdapter,
+ CreateResourceInput,
+ ResourceQuery,
+ ServiceSchema,
+} from '../cloud-spi/types'
+
+const API_VERSION = '7.4'
+
+interface KeyVaultSecretAttributes {
+ enabled?: boolean
+ created?: number
+ updated?: number
+ exp?: number | null
+ nbf?: number | null
+ recoveryLevel?: string
+ recoverableDays?: number
+}
+
+interface KeyVaultSecretRecord {
+ id?: string
+ value?: string
+ attributes?: KeyVaultSecretAttributes
+ contentType?: string
+ tags?: Record
+}
+
+interface KeyVaultSecretListResponse {
+ value?: KeyVaultSecretRecord[]
+ nextLink?: string | null
+}
+
+export class AzureKeyVaultAdapter implements CloudServiceAdapter {
+ readonly cloud = 'azure' as const
+ readonly service = 'secrets' as const
+
+ constructor(private readonly client: AzureRuntimeClient = azure) {}
+
+ schema(): ServiceSchema {
+ return azureSecretsSchema()
+ }
+
+ async list(query: ResourceQuery = {}): Promise {
+ const records: KeyVaultSecretRecord[] = []
+ let path: string | null = `/secrets?api-version=${API_VERSION}`
+
+ while (path) {
+ const body: KeyVaultSecretListResponse | null = await this.keyVaultJson(
+ path,
+ {method: 'GET'},
+ {emptyOnNotFound: true},
+ )
+ records.push(...(body?.value ?? []))
+ path = body?.nextLink ? keyVaultNextPath(body.nextLink) : null
+ }
+
+ return filterBySearch(records.map((record) => toSecretResource(record)), query.search)
+ }
+
+ async get(id: string): Promise {
+ const body = await this.keyVaultJson(
+ `/secrets/${encodeURIComponent(id)}?api-version=${API_VERSION}`,
+ {method: 'GET'},
+ {emptyOnNotFound: true},
+ )
+
+ return body ? toSecretResource(body, id) : null
+ }
+
+ async create(input: CreateResourceInput): Promise {
+ const secretName = stringValue(input.values.secretName ?? input.values.name)
+ const secretValue = stringValue(input.values.secretValue ?? input.values.value, false)
+ const contentType = stringValue(input.values.contentType)
+
+ if (!secretName) throw new Error('secretName is required')
+ if (!/^[0-9A-Za-z-]{1,127}$/.test(secretName)) {
+ throw new Error('Use a valid Key Vault secret name: 1-127 letters, numbers, or hyphens.')
+ }
+ if (!secretValue) throw new Error('secretValue is required')
+
+ const body = await this.keyVaultJson(
+ `/secrets/${encodeURIComponent(secretName)}?api-version=${API_VERSION}`,
+ {
+ method: 'PUT',
+ body: JSON.stringify({
+ value: secretValue,
+ ...(contentType ? {contentType} : {}),
+ }),
+ },
+ )
+
+ if (!body) throw new Error('Azure Key Vault create returned an empty response')
+ return toSecretResource(body, secretName)
+ }
+
+ async delete(id: string): Promise {
+ await this.client.fetch(
+ this.keyVaultPath(`/secrets/${encodeURIComponent(id)}?api-version=${API_VERSION}`),
+ {method: 'DELETE', headers: keyVaultHeaders()},
+ {emptyOnNotFound: true, includeStorageApiVersion: false},
+ )
+ }
+
+ private async keyVaultJson(
+ path: string,
+ init: RequestInit,
+ options?: {emptyOnNotFound?: boolean},
+ ): Promise {
+ const res = await this.client.fetch(
+ this.keyVaultPath(path),
+ {
+ ...init,
+ headers: {
+ ...keyVaultHeaders(),
+ ...(init.headers ?? {}),
+ },
+ },
+ {...options, includeStorageApiVersion: false},
+ )
+
+ if (!res || res.status === 204) return null
+ return await res.json() as T
+ }
+
+ private keyVaultPath(path: string): string {
+ return `/${encodeURIComponent(this.client.accountName)}-keyvault${path}`
+ }
+}
+
+function keyVaultNextPath(nextLink: string): string {
+ try {
+ const url = new URL(nextLink)
+ return `${url.pathname}${url.search}`
+ } catch {
+ return nextLink.startsWith('/') ? nextLink : `/${nextLink}`
+ }
+}
+
+function keyVaultHeaders(): Record {
+ return {
+ accept: 'application/json',
+ authorization: 'Bearer floci-ui',
+ 'content-type': 'application/json',
+ }
+}
+
+function toSecretResource(record: KeyVaultSecretRecord, fallbackName = ''): CloudResource {
+ const parsed = parseSecretId(record.id)
+ const name = parsed.name || fallbackName
+ const attributes = record.attributes ?? {}
+
+ return {
+ id: name,
+ name,
+ cloud: 'azure',
+ service: 'secrets',
+ type: 'secret',
+ region: null,
+ createdAt: epochDate(attributes.created),
+ status: attributes.enabled === false ? 'disabled' : 'enabled',
+ version: parsed.version,
+ metadata: {
+ provider: 'azure',
+ secretsService: 'key-vault',
+ vaultAccount: parsed.account,
+ contentType: record.contentType || null,
+ updatedAt: epochDate(attributes.updated),
+ expiresAt: epochDate(attributes.exp),
+ notBefore: epochDate(attributes.nbf),
+ recoveryLevel: attributes.recoveryLevel,
+ recoverableDays: attributes.recoverableDays,
+ tags: Object.entries(record.tags ?? {}).map(([key, value]) => ({key, value})),
+ },
+ }
+}
+
+function parseSecretId(id?: string): {account: string | null; name: string; version: string | null} {
+ if (!id) return {account: null, name: '', version: null}
+ try {
+ const url = new URL(id)
+ const parts = url.pathname.split('/').filter(Boolean)
+ return {
+ account: url.hostname.split('.')[0] || null,
+ name: decodeURIComponent(parts[1] ?? ''),
+ version: parts[2] ? decodeURIComponent(parts[2]) : null,
+ }
+ } catch {
+ const parts = id.split('/').filter(Boolean)
+ const secretsIndex = parts.lastIndexOf('secrets')
+ return {
+ account: null,
+ name: decodeURIComponent(parts[secretsIndex + 1] ?? ''),
+ version: parts[secretsIndex + 2] ? decodeURIComponent(parts[secretsIndex + 2]) : null,
+ }
+ }
+}
+
+function epochDate(value?: number | null): string | null {
+ if (typeof value !== 'number' || !Number.isFinite(value)) return null
+ return new Date(value * 1000).toISOString()
+}
+
+function stringValue(value: unknown, trim = true): string {
+ if (typeof value !== 'string') return ''
+ return trim ? value.trim() : value
+}
+
+function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
+ const normalized = search?.trim().toLowerCase()
+ if (!normalized) return resources
+ return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
+}
diff --git a/packages/api/src/azure.test.ts b/packages/api/src/azure.test.ts
index 4cc9e9e..35db2cb 100644
--- a/packages/api/src/azure.test.ts
+++ b/packages/api/src/azure.test.ts
@@ -8,6 +8,22 @@ afterEach(() => {
})
describe('AzureRestRuntimeClient', () => {
+ test('can omit the Blob Storage API version header', async () => {
+ let requestHeaders: HeadersInit | undefined
+ globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => {
+ requestHeaders = init?.headers
+ return new Response('{}')
+ }) as unknown as typeof fetch
+
+ const client = new AzureRestRuntimeClient('http://localhost:4577', 'devstoreaccount1')
+
+ await client.fetch('/devstoreaccount1-keyvault/secrets', {method: 'GET'}, {
+ includeStorageApiVersion: false,
+ })
+
+ expect(new Headers(requestHeaders).has('x-ms-version')).toBe(false)
+ })
+
test('adds endpoint context to network failures', async () => {
globalThis.fetch = (async () => {
throw new Error('connection refused')
diff --git a/packages/api/src/azure.ts b/packages/api/src/azure.ts
index b8ea2ba..8b57243 100644
--- a/packages/api/src/azure.ts
+++ b/packages/api/src/azure.ts
@@ -1,5 +1,6 @@
export interface AzureRuntimeFetchOptions {
emptyOnNotFound?: boolean
+ includeStorageApiVersion?: boolean
}
export interface AzureRuntimeClient {
@@ -20,7 +21,7 @@ export class AzureRestRuntimeClient implements AzureRuntimeClient {
res = await globalThis.fetch(`${this.endpoint}${path}`, {
...init,
headers: {
- 'x-ms-version': '2021-12-02',
+ ...(options.includeStorageApiVersion === false ? {} : {'x-ms-version': '2021-12-02'}),
...(init.headers ?? {}),
},
})
diff --git a/packages/api/src/cloud-spi/secretsSchema.ts b/packages/api/src/cloud-spi/secretsSchema.ts
new file mode 100644
index 0000000..023d95f
--- /dev/null
+++ b/packages/api/src/cloud-spi/secretsSchema.ts
@@ -0,0 +1,66 @@
+import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
+
+const secretsFilters: FieldSchema[] = [
+ {name: 'search', label: 'Search', type: 'text', required: false},
+]
+
+const secretsColumns: TableColumnSchema[] = [
+ {name: 'name', label: 'Secret Name'},
+ {name: 'status', label: 'Status'},
+ {name: 'createdAt', label: 'Created At'},
+ {name: 'version', label: 'Version'},
+]
+
+export function azureSecretsSchema(): ServiceSchema {
+ return {
+ cloud: 'azure',
+ service: 'secrets',
+ displayName: 'Azure Key Vault',
+ fields: [
+ {
+ name: 'secretName',
+ label: 'Secret Name',
+ type: 'text',
+ required: true,
+ description: 'Unique Key Vault secret name.',
+ validation: {
+ minLength: 1,
+ maxLength: 127,
+ pattern: '^(?:[0-9A-Za-z]|-)+$',
+ message: 'Use letters, numbers, or hyphens.',
+ },
+ },
+ {
+ name: 'secretValue',
+ label: 'Secret Value',
+ type: 'password',
+ required: true,
+ description: 'Value stored in the secret.',
+ span: true,
+ },
+ {
+ name: 'contentType',
+ label: 'Content Type',
+ type: 'text',
+ required: false,
+ description: 'Optional content type, for example application/json.',
+ },
+ ],
+ actions: ['list', 'create', 'delete', 'inspect'],
+ capabilities: {
+ resourceActions: [
+ {name: 'list', label: 'List secrets', enabled: true, status: 'available', runtimeRequired: true},
+ {name: 'create', label: 'Create secret', enabled: true, status: 'available', runtimeRequired: true},
+ {name: 'delete', label: 'Delete secret', enabled: true, status: 'available', runtimeRequired: true},
+ {name: 'inspect', label: 'Inspect metadata', enabled: true, status: 'available', runtimeRequired: true},
+ ],
+ },
+ filters: secretsFilters,
+ columns: secretsColumns,
+ }
+}
+
+export function secretsSchemaFor(cloud: CloudProvider): ServiceSchema | null {
+ if (cloud === 'azure') return azureSecretsSchema()
+ return null
+}
diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts
index bf9da0d..3e5d64a 100644
--- a/packages/api/src/cloud-spi/types.ts
+++ b/packages/api/src/cloud-spi/types.ts
@@ -1,6 +1,6 @@
export type CloudProvider = 'aws' | 'azure' | 'gcp'
-export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'serverless' | 'compute' | 'networking'
+export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'serverless' | 'compute' | 'networking' | 'secrets'
export type CloudAvailability = 'available' | 'coming_soon'
@@ -26,7 +26,7 @@ export interface CloudStatus {
error: string | null
}
-export type FieldType = 'text' | 'select'
+export type FieldType = 'text' | 'password' | 'select'
export interface FieldSchema {
name: string
@@ -83,7 +83,7 @@ export interface CloudResource {
name: string
cloud: CloudProvider
service: CloudServiceType
- type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function'
+ type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function' | 'secret'
region: string | null
createdAt: string | null
status?: string | null
diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts
index b9740c0..00443f5 100644
--- a/packages/api/src/cloudProxy.ts
+++ b/packages/api/src/cloudProxy.ts
@@ -10,6 +10,7 @@ import {GcpStorageAdapter} from './adapter-gcp/GcpStorageAdapter'
import {GcpCloudFunctionsAdapter} from './adapter-gcp/GcpCloudFunctionsAdapter'
import {CloudProxyService} from './service/CloudProxyService'
import {AzureServerlessAdapter} from './adapter-azure/AzureServerlessAdapter'
+import {AzureKeyVaultAdapter} from './adapter-azure/AzureKeyVaultAdapter'
import {AwsServerlessAdapter} from './adapter-aws/AwsServerlessAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2'
@@ -38,6 +39,7 @@ export function createCloudProxyService(accountId?: string | null): CloudProxySe
new GcpStorageAdapter(),
new GcpCloudFunctionsAdapter(),
new AzureServerlessAdapter(),
+ new AzureKeyVaultAdapter(),
])
return new CloudProxyService(registry)
diff --git a/packages/api/src/routes/clouds.test.ts b/packages/api/src/routes/clouds.test.ts
index 49296be..3c00a16 100644
--- a/packages/api/src/routes/clouds.test.ts
+++ b/packages/api/src/routes/clouds.test.ts
@@ -2,6 +2,7 @@ import {describe, expect, test} from 'bun:test'
import {Hono} from 'hono'
import {azureDatabaseSchema} from '../cloud-spi/databaseSchema'
import {awsStorageSchema, azureStorageSchema} from '../cloud-spi/storageSchema'
+import {azureSecretsSchema} from '../cloud-spi/secretsSchema'
import type {CloudResource, CloudServiceAdapter, CosmosContainer, CosmosItem, CosmosQueryResult, CreateResourceInput} from '../cloud-spi/types'
import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry'
import {CloudProxyService} from '../service/CloudProxyService'
@@ -116,6 +117,35 @@ describe('cloud schema routes', () => {
expect(gcpBody.displayName).toBe('Cloud SQL')
})
+ test('returns Azure Key Vault schema without a registered adapter', async () => {
+ const res = await appWithRoutes().request('/api/clouds/azure/services/secrets/schema')
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.displayName).toBe('Azure Key Vault')
+ expect(body.fields.map((field: {name: string}) => field.name)).toEqual([
+ 'secretName',
+ 'secretValue',
+ 'contentType',
+ ])
+ })
+
+ test('marks Azure Key Vault available when its adapter is registered', async () => {
+ const app = appWithRoutes([mockAdapter('azure', {
+ service: 'secrets',
+ schema: azureSecretsSchema,
+ })])
+ const res = await app.request('/api/clouds/azure/services')
+ const body = await res.json()
+ const secrets = body.find((service: {service: string}) => service.service === 'secrets')
+
+ expect(res.status).toBe(200)
+ expect(secrets).toMatchObject({
+ displayName: 'Key Vault',
+ availability: 'available',
+ })
+ })
+
test('returns AWS cloud status', async () => {
const res = await appWithRoutes().request('/api/clouds/aws/status')
const body = await res.json()
diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts
index 93cb831..a2a64e3 100644
--- a/packages/api/src/routes/clouds.ts
+++ b/packages/api/src/routes/clouds.ts
@@ -261,7 +261,7 @@ function isCloudProvider(value: string): value is CloudProvider {
}
function isServiceType(value: string): value is CloudServiceType {
- return value === 'storage' || value === 'k8s' || value === 'database' || value === 'serverless' || value === 'compute' || value === 'networking'
+ return value === 'storage' || value === 'k8s' || value === 'database' || value === 'serverless' || value === 'compute' || value === 'networking' || value === 'secrets'
}
async function withRuntime(c: Context, handler: () => Promise): Promise {
diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts
index 826bf12..e101ca2 100644
--- a/packages/api/src/service/CloudProxyService.ts
+++ b/packages/api/src/service/CloudProxyService.ts
@@ -20,6 +20,7 @@ import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry'
import {serverlessSchemaFor} from '../cloud-spi/serverlessSchema'
import {k8sSchemaFor} from '../cloud-spi/eksSchema'
import {databaseSchemaFor} from '../cloud-spi/databaseSchema'
+import {secretsSchemaFor} from '../cloud-spi/secretsSchema'
import {azureEndpoint} from '../azure'
import {checkGcpRuntime, gcpEndpoint} from '../gcp'
@@ -73,6 +74,12 @@ export class CloudProxyService {
displayName: 'Networking',
availability: this.registry.get(cloud, 'networking') ? 'available' : 'coming_soon',
})
+ services.push({
+ cloud,
+ service: 'secrets',
+ displayName: cloud === 'azure' ? 'Key Vault' : 'Secrets',
+ availability: this.registry.get(cloud, 'secrets') ? 'available' : 'coming_soon',
+ })
return services
}
@@ -83,6 +90,7 @@ export class CloudProxyService {
if (service === 'k8s') return k8sSchemaFor(cloud)
if (service === 'database') return databaseSchemaFor(cloud)
if (service === 'serverless') return serverlessSchemaFor(cloud)
+ if (service === 'secrets') return secretsSchemaFor(cloud)
return null
}
diff --git a/packages/frontend/src/components/DynamicFormRenderer.tsx b/packages/frontend/src/components/DynamicFormRenderer.tsx
index 0c718b6..fc6e17a 100644
--- a/packages/frontend/src/components/DynamicFormRenderer.tsx
+++ b/packages/frontend/src/components/DynamicFormRenderer.tsx
@@ -89,6 +89,7 @@ function FieldInput({field, value, invalid, onChange}: {field: FieldSchema; valu
return (
type CloudSidebarService = keyof typeof CLOUD_SERVICE_ICONS
@@ -52,6 +53,7 @@ const CLOUD_SERVICE_ITEMS: Array<{name: CloudSidebarService; label: string; rout
{name: 'networking', label: 'Networking', route: 'networking'},
{name: 'secretsmanager', label: 'Secrets Manager', route: '/secretsmanager'},
{name: 'serverless', label: 'Serverless', route: 'serverless'},
+ {name: 'secrets', label: 'Key Vault', route: 'secrets'},
{name: 'queue', label: 'Queue'},
{name: 'function', label: 'Function'},
]
@@ -71,6 +73,7 @@ function CloudServiceNav() {
|| (service.name === 'database' && (cloud === 'aws' || cloud === 'azure'))
|| ((service.name === 'k8s' || service.name === 'compute' || service.name === 'networking') && cloud === 'aws')
|| (service.name === 'serverless' && (cloud === 'aws' || cloud === 'azure'))
+ || (service.name === 'secrets' && cloud === 'azure')
if (service.route && available) {
const target = service.route.startsWith('/') ? service.route : `/cloud-explorer/${cloud}/${service.route}`
return
diff --git a/packages/frontend/src/pages/CloudExplorerPage.tsx b/packages/frontend/src/pages/CloudExplorerPage.tsx
index 37efef0..a4c9927 100644
--- a/packages/frontend/src/pages/CloudExplorerPage.tsx
+++ b/packages/frontend/src/pages/CloudExplorerPage.tsx
@@ -102,7 +102,7 @@ function normalizeCloud(value?: string): CloudProvider | null {
}
function normalizeService(value?: string): CloudServiceType | null {
- return value === 'storage' || value === 'k8s' || value === 'database' || value === 'compute' || value === 'networking' || value === 'serverless' ? value : null
+ return value === 'storage' || value === 'k8s' || value === 'database' || value === 'compute' || value === 'networking' || value === 'serverless' || value === 'secrets' ? value : null
}
function ServiceInfoDialog({
@@ -256,6 +256,7 @@ function connectionValue(status?: CloudStatus, loading?: boolean): string {
function serviceLabel(service: CloudServiceType): string {
if (service === 'k8s') return 'k8s Engine'
if (service === 'serverless') return 'Serverless'
+ if (service === 'secrets') return 'Secrets'
return service.charAt(0).toUpperCase() + service.slice(1)
}
@@ -263,6 +264,7 @@ function limitationCopy(cloud: CloudProvider, service: CloudServiceType): string
if (cloud === 'aws' && service === 'storage') return 'Advanced S3 workflows such as bulk actions, version browsing, and richer object lifecycle controls still live outside the normalized surface.'
if (cloud === 'azure' && service === 'storage') return 'Blob Storage is wired through the normalized contract, but advanced metadata, tags, and access-policy workflows are still limited.'
if (cloud === 'azure' && service === 'database') return 'Cosmos DB uses a richer panel below for databases, containers, items, and SQL queries; a fully normalized database model is still evolving.'
+ if (cloud === 'azure' && service === 'secrets') return 'Key Vault secret metadata and lifecycle operations are wired through the normalized contract; secret value versioning and recovery workflows are not exposed yet.'
if (cloud === 'aws' && service === 'compute') return 'Compute workflows still rely on AWS-specific forms for dependent infrastructure choices such as VPC, subnet, and security group.'
if (cloud === 'aws' && service === 'networking') return 'Networking uses AWS-specific operational panels because many actions require nested workflows instead of a flat generic form.'
return 'This service is available through the current adapter, but the normalized contract is still expanding.'
diff --git a/packages/frontend/src/types/cloud.ts b/packages/frontend/src/types/cloud.ts
index 668a147..f6b1895 100644
--- a/packages/frontend/src/types/cloud.ts
+++ b/packages/frontend/src/types/cloud.ts
@@ -1,6 +1,6 @@
export type CloudProvider = 'aws' | 'azure' | 'gcp'
export type CloudAvailability = 'available' | 'coming_soon'
-export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'compute' | 'networking' | 'serverless'
+export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'compute' | 'networking' | 'serverless' | 'secrets'
export interface CloudDescriptor {
id: CloudProvider
diff --git a/packages/frontend/src/types/resource.ts b/packages/frontend/src/types/resource.ts
index 71f464f..a69e1df 100644
--- a/packages/frontend/src/types/resource.ts
+++ b/packages/frontend/src/types/resource.ts
@@ -5,7 +5,7 @@ export interface CloudResource {
name: string
cloud: CloudProvider
service: CloudServiceType
- type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | "azure-function" | 'gcp-function';
+ type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | "azure-function" | 'gcp-function' | 'secret';
region: string | null
createdAt: string | null
status?: string | null
diff --git a/packages/frontend/src/types/schema.ts b/packages/frontend/src/types/schema.ts
index 06e4985..3efda8a 100644
--- a/packages/frontend/src/types/schema.ts
+++ b/packages/frontend/src/types/schema.ts
@@ -1,6 +1,6 @@
import type {CloudProvider, CloudServiceType} from './cloud'
-export type FieldType = 'text' | 'select'
+export type FieldType = 'text' | 'password' | 'select'
export type ActionSchema = 'list' | 'create' | 'delete' | 'inspect'
export type ResourceActionName = 'list' | 'create' | 'delete' | 'inspect'
export type ObjectActionName = 'list' | 'upload' | 'download' | 'delete' | 'createFolder' | 'copy'