From 5178952bcbab6dc088c5f0b9c6d9049e33df5351 Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Sat, 5 Sep 2026 00:32:56 -0400 Subject: [PATCH 1/5] Replace browser vault with managed OpenBao credential forms --- package-lock.json | 21 +- package.json | 6 +- src/lib/projects/workflow-form-adapters.ts | 2 +- src/lib/services/form-adapters.ts | 313 ++---------------- src/lib/services/navigation.ts | 3 +- src/lib/services/service-vault-crypto.ts | 15 - src/pages/app/services/[connectionId].astro | 184 ++-------- src/pages/app/services/index.astro | 11 +- src/pages/app/services/vault.astro | 284 ---------------- src/routes.ts | 1 - .../contextual-help-architecture.test.ts | 19 +- tests/contract/package/admin-package.test.ts | 1 - .../service-management-architecture.test.ts | 40 +-- .../unit/services/managed-credentials.test.ts | 32 ++ 14 files changed, 127 insertions(+), 805 deletions(-) delete mode 100644 src/lib/services/service-vault-crypto.ts delete mode 100644 src/pages/app/services/vault.astro create mode 100644 tests/unit/services/managed-credentials.test.ts diff --git a/package-lock.json b/package-lock.json index 84337b81..0385ca56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "@treeseed/admin", - "version": "0.12.59-rc.26", + "version": "0.12.59-rc.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@treeseed/admin", - "version": "0.12.59-rc.26", + "version": "0.12.59-rc.27", "license": "Apache-2.0", "dependencies": { "@astrojs/starlight": "0.37.6", "@react-email/render": "^2.0.8", "@treeseed/core": ">=0.12.60-rc.1 <0.14.0", - "@treeseed/sdk": "0.13.0-rc.85", - "@treeseed/ui": "^0.12.18-rc.11", + "@treeseed/sdk": "0.13.0-rc.87", + "@treeseed/ui": "0.12.18-rc.13", "react": "^19.2.5", "react-dom": "^19.2.6" }, @@ -6961,9 +6961,9 @@ } }, "node_modules/@treeseed/sdk": { - "version": "0.13.0-rc.85", - "resolved": "https://registry.npmjs.org/@treeseed/sdk/-/sdk-0.13.0-rc.85.tgz", - "integrity": "sha512-TgxdFRvUr0P0hYh+4UrT1DbRfctnuldmAEd6BEH/mdpImDV1PhTQKX+x50u8P5ZKGmZAArvCSQabK5Nt60nN6Q==", + "version": "0.13.0-rc.87", + "resolved": "https://registry.npmjs.org/@treeseed/sdk/-/sdk-0.13.0-rc.87.tgz", + "integrity": "sha512-riRs/2K54dGhOWX1mmYeo2Ffe2gnowJc346g3DyWtQJ8C/+xC5fMQFxRthp265lccgDYm9VGWMZj7rOZzBeYsQ==", "dependencies": { "@treeseed/treedx": "0.3.0-rc.4", "esbuild": "^0.28.0", @@ -6986,10 +6986,9 @@ } }, "node_modules/@treeseed/ui": { - "version": "0.12.18-rc.12", - "resolved": "https://registry.npmjs.org/@treeseed/ui/-/ui-0.12.18-rc.12.tgz", - "integrity": "sha512-UxQYwVuZRIwTj87pvHQFUoKCKnZQqY/kASXaLpuQhQOfp+gnK7PjkfWEQkygeXplTB5ShUgcwmGJBtuwslZDyQ==", - "license": "Apache-2.0", + "version": "0.12.18-rc.13", + "resolved": "https://registry.npmjs.org/@treeseed/ui/-/ui-0.12.18-rc.13.tgz", + "integrity": "sha512-Zq6rg9ONtFAbSK6TGemuvoEPoC6ILvRdVzj1zFRonhxPwn1B8tXL3yBnP5DwmnqYY1T6wkajp1D3FvBIVKG8Cg==", "dependencies": { "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.3", diff --git a/package.json b/package.json index 878fba5b..f04bd05d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@treeseed/admin", - "version": "0.12.59-rc.26", + "version": "0.12.59-rc.27", "description": "Treeseed administration portal framework built on @treeseed/core and @treeseed/ui.", "license": "Apache-2.0", "repository": { @@ -62,8 +62,8 @@ "@astrojs/starlight": "0.37.6", "@react-email/render": "^2.0.8", "@treeseed/core": ">=0.12.60-rc.1 <0.14.0", - "@treeseed/sdk": "0.13.0-rc.85", - "@treeseed/ui": "^0.12.18-rc.11", + "@treeseed/sdk": "0.13.0-rc.87", + "@treeseed/ui": "0.12.18-rc.13", "react": "^19.2.5", "react-dom": "^19.2.6" }, diff --git a/src/lib/projects/workflow-form-adapters.ts b/src/lib/projects/workflow-form-adapters.ts index aa1bc20a..6582d545 100644 --- a/src/lib/projects/workflow-form-adapters.ts +++ b/src/lib/projects/workflow-form-adapters.ts @@ -1,4 +1,4 @@ -import { encryptGitHubActionsSecret } from '../services/service-vault-crypto.ts'; +import { encryptGitHubActionsSecret } from '@treeseed/sdk/secrets-capability'; import { registerFormAdapter } from '@treeseed/ui/forms/client'; const text = (data: FormData, key: string) => String(data.get(key) ?? '').trim(); diff --git a/src/lib/services/form-adapters.ts b/src/lib/services/form-adapters.ts index 2f8e3d02..27d56b39 100644 --- a/src/lib/services/form-adapters.ts +++ b/src/lib/services/form-adapters.ts @@ -1,55 +1,12 @@ -import { - SERVICE_VAULT_ENCRYPTION_VERSION, - canonicalServiceVaultAssociatedData, -} from '@treeseed/sdk/secrets-capability'; -import { - clearServiceVaultKey, - createServiceVaultKey, - createServiceVaultUserKeyPair, - createTeamVaultGrant, - decryptServiceCredential, - decryptServiceVaultPrivateKey, - encryptServiceCredential, - encryptServiceVaultPrivateKey, - openTeamVaultGrant, - rewrapServiceCredential, - sealSecretOperationPayload, -} from './service-vault-crypto.ts'; -import { registerFormAdapter, sendFormRequest } from '@treeseed/ui/forms/client'; - +import { registerFormAdapter } from '@treeseed/ui/forms/client'; function jsonRequest(url: string, body: unknown, csrfToken: string, method = 'POST') { - return { - url, - init: { - method, - headers: { - accept: 'application/json', - 'content-type': 'application/json', - 'x-treeseed-csrf': csrfToken, - 'x-treeseed-form': 'enhanced', - }, - body: JSON.stringify(body), - credentials: 'same-origin' as const, - }, - }; -} - -function text(data: FormData, key: string) { - return String(data.get(key) ?? '').trim(); + return {url, init: {method, headers: {accept: 'application/json', 'content-type': 'application/json', + 'x-treeseed-csrf': csrfToken, 'x-treeseed-form': 'enhanced', 'Idempotency-Key': crypto.randomUUID()}, + body: JSON.stringify(body), credentials: 'same-origin' as const}}; } - +function text(data: FormData, key: string) { return String(data.get(key) ?? '').trim(); } export function registerServiceFormAdapters() { - const disposers = [ - registerFormAdapter('provider-authority', { - buildRequest(context) { - const body: Record = { - scheme: text(context.formData, 'scheme'), reference: text(context.formData, 'reference'), - }; - const version = text(context.formData, 'version'); - if (version) body.version = Number(version); - return jsonRequest(context.form.action, body, text(context.formData, 'csrfToken'), 'PUT'); - }, - }), + const disposers = [ registerFormAdapter('github-connector', { buildRequest(context) { return jsonRequest(context.form.action, { @@ -82,247 +39,19 @@ export function registerServiceFormAdapters() { return jsonRequest(context.form.action, body, text(context.formData, 'csrfToken'), context.form.dataset.tsMethod || 'POST'); }, }), - registerFormAdapter('personal-vault-key', { - async buildRequest(context) { - const passphrase = text(context.formData, 'passphrase'); - const pair = await createServiceVaultUserKeyPair(); - try { - const encryptedPrivateKeyEnvelope = await encryptServiceVaultPrivateKey(pair.privateKey, pair.publicKey, passphrase); - const request = jsonRequest(context.form.action, { - publicKey: pair.publicKey, - encryptedPrivateKeyEnvelope, - }, text(context.formData, 'csrfToken')); - request.init.method = 'PUT'; - return request; - } finally { - clearServiceVaultKey(pair.privateKey); - } - }, - }), - registerFormAdapter('personal-vault-rotate', { - async buildRequest(context) { - const existing = JSON.parse(text(context.formData, 'encryptedPrivateKeyEnvelope')); - const privateKey = await decryptServiceVaultPrivateKey(existing, text(context.formData, 'currentPassphrase')); - try { - const encryptedPrivateKeyEnvelope = await encryptServiceVaultPrivateKey( - privateKey, - existing.publicKey, - text(context.formData, 'passphrase'), - ); - return jsonRequest(context.form.action, { - publicKey: existing.publicKey, - encryptedPrivateKeyEnvelope, - }, text(context.formData, 'csrfToken'), 'PUT'); - } finally { - clearServiceVaultKey(privateKey); - } - }, - }), - registerFormAdapter('team-vault-initialize', { - async buildRequest(context) { - const encryptedPrivateKeyEnvelope = JSON.parse(text(context.formData, 'encryptedPrivateKeyEnvelope')); - const privateKey = await decryptServiceVaultPrivateKey(encryptedPrivateKeyEnvelope, text(context.formData, 'passphrase')); - const teamVaultKey = await createServiceVaultKey(); - try { - const grant = await createTeamVaultGrant(teamVaultKey, encryptedPrivateKeyEnvelope.publicKey); - return jsonRequest(context.form.action, { - userVaultKeyId: text(context.formData, 'userVaultKeyId'), - wrappedTeamVaultKey: grant.wrappedTeamVaultKey, - encryptionVersion: SERVICE_VAULT_ENCRYPTION_VERSION, - }, text(context.formData, 'csrfToken')); - } finally { - clearServiceVaultKey(privateKey); - clearServiceVaultKey(teamVaultKey); - } - }, - }), - registerFormAdapter('team-vault-reset', { - async buildRequest(context) { - const encryptedPrivateKeyEnvelope = JSON.parse(text(context.formData, 'encryptedPrivateKeyEnvelope')); - const privateKey = await decryptServiceVaultPrivateKey(encryptedPrivateKeyEnvelope, text(context.formData, 'passphrase')); - const teamVaultKey = await createServiceVaultKey(); - try { - const grant = await createTeamVaultGrant(teamVaultKey, encryptedPrivateKeyEnvelope.publicKey); - return jsonRequest(context.form.action, { - userVaultKeyId: text(context.formData, 'userVaultKeyId'), - wrappedTeamVaultKey: grant.wrappedTeamVaultKey, - encryptionVersion: SERVICE_VAULT_ENCRYPTION_VERSION, - confirmation: text(context.formData, 'confirmation'), - currentPassword: String(context.formData.get('currentPassword') ?? ''), - }, text(context.formData, 'csrfToken')); - } finally { - clearServiceVaultKey(privateKey); - clearServiceVaultKey(teamVaultKey); - } - }, - }), - registerFormAdapter('team-vault-rotate', { - async buildRequest(context) { - const privateEnvelope = JSON.parse(text(context.formData, 'encryptedPrivateKeyEnvelope')); - const privateKey = await decryptServiceVaultPrivateKey(privateEnvelope, text(context.formData, 'passphrase')); - const currentTeamKey = await openTeamVaultGrant({ - version: SERVICE_VAULT_ENCRYPTION_VERSION, - algorithm: 'x25519-sealed-box', - recipientPublicKey: privateEnvelope.publicKey, - wrappedTeamVaultKey: text(context.formData, 'wrappedTeamVaultKey'), - }, privateKey); - const replacementTeamKey = await createServiceVaultKey(); - try { - const envelopes = JSON.parse(text(context.formData, 'envelopes')) as Array<{ id: string; envelope: any }>; - const grants = JSON.parse(text(context.formData, 'grants')) as Array<{ userId: string; userVaultKeyId: string; publicKey: string }>; - const replacements = await Promise.all(envelopes.map(async (item) => ({ - id: item.id, - envelope: await rewrapServiceCredential(item.envelope, currentTeamKey, replacementTeamKey), - }))); - const replacementGrants = await Promise.all(grants.map(async (grant) => ({ - userId: grant.userId, - userVaultKeyId: grant.userVaultKeyId, - wrappedTeamVaultKey: (await createTeamVaultGrant(replacementTeamKey, grant.publicKey)).wrappedTeamVaultKey, - }))); - return jsonRequest(context.form.action, { - expectedKeyVersion: Number(text(context.formData, 'expectedKeyVersion')), - envelopes: replacements, - grants: replacementGrants, - }, text(context.formData, 'csrfToken')); - } finally { - clearServiceVaultKey(privateKey); - clearServiceVaultKey(currentTeamKey); - clearServiceVaultKey(replacementTeamKey); - } - }, - }), - registerFormAdapter('team-vault-grant', { - async buildRequest(context) { - const encryptedPrivateKeyEnvelope = JSON.parse(text(context.formData, 'encryptedPrivateKeyEnvelope')); - const privateKey = await decryptServiceVaultPrivateKey(encryptedPrivateKeyEnvelope, text(context.formData, 'passphrase')); - const ownGrant = { - version: SERVICE_VAULT_ENCRYPTION_VERSION, - algorithm: 'x25519-sealed-box' as const, - recipientPublicKey: encryptedPrivateKeyEnvelope.publicKey, - wrappedTeamVaultKey: text(context.formData, 'wrappedTeamVaultKey'), - }; - const teamVaultKey = await openTeamVaultGrant(ownGrant, privateKey); - try { - const recipient = await createTeamVaultGrant(teamVaultKey, text(context.formData, 'recipientPublicKey')); - return jsonRequest(context.form.action, { - userId: text(context.formData, 'userId'), - userVaultKeyId: text(context.formData, 'userVaultKeyId'), - wrappedTeamVaultKey: recipient.wrappedTeamVaultKey, - }, text(context.formData, 'csrfToken')); - } finally { - clearServiceVaultKey(privateKey); - clearServiceVaultKey(teamVaultKey); - } - }, - }), - registerFormAdapter('service-credential', { - async buildRequest(context) { - const encryptedPrivateKeyEnvelope = JSON.parse(text(context.formData, 'encryptedPrivateKeyEnvelope')); - const privateKey = await decryptServiceVaultPrivateKey(encryptedPrivateKeyEnvelope, text(context.formData, 'passphrase')); - const grant = { - version: SERVICE_VAULT_ENCRYPTION_VERSION, - algorithm: 'x25519-sealed-box' as const, - recipientPublicKey: encryptedPrivateKeyEnvelope.publicKey, - wrappedTeamVaultKey: text(context.formData, 'wrappedTeamVaultKey'), - }; - const teamVaultKey = await openTeamVaultGrant(grant, privateKey); - try { - const definitionId = text(context.formData, 'definitionId'); - const fieldKey = text(context.formData, 'fieldKey'); - const keyVersion = Number(text(context.formData, 'keyVersion')); - const associatedData = canonicalServiceVaultAssociatedData({ - teamId: text(context.formData, 'teamId'), - connectionId: text(context.formData, 'connectionId'), - credentialProfileId: definitionId, - field: fieldKey, - purpose: 'team-service-credential', - version: keyVersion, - }); - const envelope = await encryptServiceCredential( - String(context.formData.get('credentialValue') ?? ''), - teamVaultKey, - associatedData, - ); - return jsonRequest(context.form.action, { - definitionId, - fieldKey, - keyVersion, - envelope, - }, text(context.formData, 'csrfToken')); - } finally { - clearServiceVaultKey(privateKey); - clearServiceVaultKey(teamVaultKey); - } - }, - }), - registerFormAdapter('service-validation', { - buildRequest(context) { - return jsonRequest(context.form.action, { - connectionId: text(context.formData, 'connectionId'), - capabilityType: text(context.formData, 'capabilityType'), - credentialProfileId: text(context.formData, 'credentialProfileId'), - purpose: text(context.formData, 'purpose'), - idempotencyKey: text(context.formData, 'idempotencyKey'), - }, text(context.formData, 'csrfToken')); - }, - async afterSuccess(result, context) { - const lease = result.payload as { id: string; publicKey?: string }; - const teamId = text(context.formData, 'teamId'); - const privateEnvelope = JSON.parse(text(context.formData, 'encryptedPrivateKeyEnvelope')); - const privateKey = await decryptServiceVaultPrivateKey(privateEnvelope, text(context.formData, 'passphrase')); - const teamVaultKey = await openTeamVaultGrant({ - version: SERVICE_VAULT_ENCRYPTION_VERSION, - algorithm: 'x25519-sealed-box', - recipientPublicKey: privateEnvelope.publicKey, - wrappedTeamVaultKey: text(context.formData, 'wrappedTeamVaultKey'), - }, privateKey); - try { - let current = lease; - for (let attempt = 0; attempt < 30 && !current.publicKey; attempt += 1) { - await new Promise((resolve) => window.setTimeout(resolve, 500)); - const response = await sendFormRequest({ - url: `/v1/teams/${encodeURIComponent(teamId)}/service-operation-leases/${encodeURIComponent(lease.id)}`, - init: { headers: { accept: 'application/json' }, credentials: 'same-origin' }, - }); - const body = await response.json(); - if (!response.ok || !body.ok) throw new Error(body.message ?? 'The operations runner did not accept the lease.'); - current = body.payload; - } - if (!current.publicKey) throw new Error('The operations runner did not issue an ephemeral key before the lease expired.'); - const envelopes = JSON.parse(text(context.formData, 'envelopes')) as Array<{ fieldKey: string; envelope: any }>; - const values = Object.fromEntries(await Promise.all(envelopes.map(async (item) => [ - item.fieldKey, - await decryptServiceCredential(item.envelope, teamVaultKey, item.envelope.associatedData), - ]))); - const sealedPayload = await sealSecretOperationPayload(values, current.publicKey); - const delivered = await sendFormRequest(jsonRequest( - `/v1/teams/${encodeURIComponent(teamId)}/service-operation-leases/${encodeURIComponent(lease.id)}/payload`, - { sealedPayload }, - text(context.formData, 'csrfToken'), - 'PUT', - )); - const deliveredBody = await delivered.json(); - if (!delivered.ok || !deliveredBody.ok) throw new Error(deliveredBody.message ?? 'The sealed validation payload was rejected.'); - result.message = 'Read-only provider validation authorized. The result will appear in connection activity.'; - } finally { - clearServiceVaultKey(privateKey); - clearServiceVaultKey(teamVaultKey); - } - }, - }), - registerFormAdapter('external-vault-binding', { - buildRequest(context) { - return jsonRequest(context.form.action, { - provider: 'openbao', - authMode: text(context.formData, 'authMode'), - reference: { - address: text(context.formData, 'address'), - mount: text(context.formData, 'mount'), - role: text(context.formData, 'role'), - }, - }, text(context.formData, 'csrfToken')); - }, - }), - ]; - return () => disposers.forEach((dispose) => dispose()); + + registerFormAdapter('managed-credentials', { + buildRequest(context) { + const method = context.form.dataset.tsMethod || 'PUT'; + const body: Record = {expectedVersion: Number(text(context.formData, 'expectedVersion'))}; + if (method === 'PUT') body.values = Object.fromEntries([...context.formData.entries()] + .filter(([key,value]) => key.startsWith('secret.') && String(value).length > 0) + .map(([key,value]) => [key.slice(7),String(value)])); + const request = jsonRequest(context.form.action,body,text(context.formData,'csrfToken'),method); + context.form.querySelectorAll('input[type="password"]').forEach(input => {input.value = '';}); + return request; + }, + }), + ]; + return () => disposers.forEach(dispose => dispose()); } diff --git a/src/lib/services/navigation.ts b/src/lib/services/navigation.ts index 3348683f..291048da 100644 --- a/src/lib/services/navigation.ts +++ b/src/lib/services/navigation.ts @@ -6,12 +6,11 @@ export interface ServiceSection { } interface ServiceNavigationOptions { - current: 'connections' | 'vault'; + current: 'connections'; } export function serviceSections(options: ServiceNavigationOptions): ServiceSection[] { return [ { id: 'connections', label: 'Connections', href: '/app/services', current: options.current === 'connections' }, - { id: 'vault', label: 'Vault', href: '/app/services/vault', current: options.current === 'vault' }, ]; } diff --git a/src/lib/services/service-vault-crypto.ts b/src/lib/services/service-vault-crypto.ts deleted file mode 100644 index 49fcaf41..00000000 --- a/src/lib/services/service-vault-crypto.ts +++ /dev/null @@ -1,15 +0,0 @@ -export { - clearServiceVaultKey, - createServiceVaultKey, - createServiceVaultUserKeyPair, - createTeamVaultGrant, - decryptServiceCredential, - decryptServiceVaultPrivateKey, - encryptGitHubActionsSecret, - encryptServiceCredential, - encryptServiceVaultPrivateKey, - openSecretOperationPayload, - openTeamVaultGrant, - rewrapServiceCredential, - sealSecretOperationPayload, -} from '@treeseed/sdk/secrets-capability'; diff --git a/src/pages/app/services/[connectionId].astro b/src/pages/app/services/[connectionId].astro index 6ce4a7b0..3858ce68 100644 --- a/src/pages/app/services/[connectionId].astro +++ b/src/pages/app/services/[connectionId].astro @@ -7,12 +7,9 @@ import Button from '@treeseed/ui/components/astro/forms/Button.astro'; import Field from '@treeseed/ui/components/astro/forms/Field.astro'; import TextInput from '@treeseed/ui/components/astro/forms/TextInput.astro'; import CapabilitySelector from '@treeseed/ui/components/astro/service/CapabilitySelector.astro'; -import OperationAuthorizationDialog from '@treeseed/ui/components/astro/service/OperationAuthorizationDialog.astro'; import PermissionChecklist from '@treeseed/ui/components/astro/service/PermissionChecklist.astro'; import ProviderMark from '@treeseed/ui/components/astro/service/ProviderMark.astro'; import ProviderAuthorityPanel from '@treeseed/ui/components/astro/service/ProviderAuthorityPanel.astro'; -import ExplicitCredentialAuthorityPanel from '@treeseed/ui/components/astro/service/ExplicitCredentialAuthorityPanel.astro'; -import SecretCustodyBadge from '@treeseed/ui/components/astro/service/SecretCustodyBadge.astro'; import EmptyState from '@treeseed/ui/components/astro/surface/EmptyState.astro'; import Panel from '@treeseed/ui/components/astro/surface/Panel.astro'; import SettingsTemplate from '@treeseed/ui/components/astro/templates/SettingsTemplate.astro'; @@ -29,12 +26,10 @@ const connectionId = String(Astro.params.connectionId ?? ''); const api = new ApiClientFacade(Astro); const preferences = await api.accountPreferences().catch(() => ({ timeZone: 'UTC' })); let connection: any = null; -let vault: any = null; -let userVaultKey: any = null; -let envelopes: any[] = []; +const credentials: Record = {}; let authorities: any[] = []; let unavailable = false; -let vaultUnavailable = false; +let custodyUnavailable = false; const managementRoles = new Set(['team_owner', 'project_lead', 'service_admin']); const canManage = Boolean(team?.roles?.some((role: string) => managementRoles.has(role)) || team?.currentUserRole && managementRoles.has(team.currentUserRole)); if (team) { @@ -47,32 +42,18 @@ if (team) { if (connection && canManage) { authorities = await api.request('GET', `/v1/teams/${encodeURIComponent(team.id)}/services/${encodeURIComponent(connectionId)}/credential-authorities`).catch(() => []); try { - [vault, userVaultKey] = await Promise.all([ - api.request('GET', `/v1/teams/${encodeURIComponent(team.id)}/vault`), - api.request('GET', '/v1/users/me/vault-key'), - ]); - if (vault?.ownGrant) { - envelopes = await api.request('GET', `/v1/teams/${encodeURIComponent(team.id)}/services/${encodeURIComponent(connectionId)}/credential-envelopes`); - } - } catch { - vaultUnavailable = true; - } + const profiles = getServiceProviderDefinition(connection.providerId)?.credentialProfiles.filter(profile => + profile.authoritySchemes.includes('openbao') && connection.capabilities.some((binding: any) => binding.status === 'configured' && binding.credentialProfileId === profile.id)) ?? []; + await Promise.all(profiles.map(async profile => { + credentials[profile.id] = await api.request('GET', `/v1/teams/${encodeURIComponent(team.id)}/services/${encodeURIComponent(connectionId)}/credentials/${encodeURIComponent(profile.id)}`); + })); + } catch { custodyUnavailable = true; } } } const provider = connection ? getServiceProviderDefinition(connection.providerId) : null; const csrfToken = ensureCsrfToken(Astro); -const profileEnvelopes = (definitionId: string) => { - const saved = connection?.credentialProfiles?.find((profile: any) => profile.definitionId === definitionId); - return envelopes.filter((item) => item.credentialProfileId === saved?.id); -}; -const validationCapability = (types: string[]) => types.find((type) => - connection?.capabilities?.some((item: any) => item.capabilityType === type && item.status === 'configured'), -); const connectorConfig = (kind: 'repository' | 'workflow') => connection?.nonSecretConfig?.githubConnectors?.[kind] ?? {}; const authorityStatus = (profileId: string) => authorities.find((item) => item.credentialProfileId === profileId)?.status ?? null; -const profileAuthority = (profileId: string) => authorities.find((item) => item.credentialProfileId === profileId) ?? null; -const environmentReference = (profileId: string) => `TREESEED_GITHUB_TOKEN_${String(connection?.nonSecretConfig?.organization ?? 'OWNER') - .toUpperCase().replace(/[^A-Z0-9]+/gu, '_')}_${profileId === 'github-repository-token' ? 'REPOSITORY' : 'WORKFLOW'}`; --- @@ -104,14 +85,9 @@ const environmentReference = (profileId: string) => `TREESEED_GITHUB_TOKEN_${Str .map(([key, value]) => ({ key: provider.connectionFields.find((field) => field.key === key)?.label ?? key, value: String(value) })), ]} /> - -
- {connection.credentialProfiles?.length - ? connection.credentialProfiles.map((profile: any) => ) - : Credentials required} -
- -
+ + OpenBao + @@ -140,119 +116,31 @@ const environmentReference = (profileId: string) => `TREESEED_GITHUB_TOKEN_${Str permissions={provider.credentialProfiles.find((item) => item.id === 'github-workflow-app')?.permissions ?? []} /> ) : null} - {provider.id === 'github' && canManage ? ( -
- {provider.credentialProfiles.filter((profile) => profile.id.endsWith('-token')).map((profile) => ( - - ))} -
- ) : null} - {vaultUnavailable ? ( - - ) : !canManage ? ( - - ) : !vault || !userVaultKey || !vault.ownGrant ? ( - - ) : ( -
- {provider.id === 'openbao' && canManage ? ( - -
- - - - - - - - - - - - - - -
-
- ) : null} - {provider.credentialProfiles.filter((profile) => provider.id !== 'github' || !profile.id.endsWith('-app')).map((profile) => ( - -
- - {envelopes.some((item) => item.credentialProfileId === connection.credentialProfiles?.find((saved: any) => saved.definitionId === profile.id)?.id) - ? Configured - : Not configured} -
- {profile.fields.filter((field) => field.sensitive).map((field) => ( -
- - - - - - - - - - - - - - - -
- ))} - - {profile.fields.some((field) => field.sensitive) && profileEnvelopes(profile.id).length === profile.fields.filter((field) => field.sensitive).length && validationCapability(profile.capabilities) ? ( -
- - - - - - - - - - - field.sensitive).map((field) => field.label)} - submitLabel="Authorize validation" - knowledgePageId="services.validation" - > - - - - -
- ) : null} -
- ))} -
- )} + {!canManage ? + : custodyUnavailable ? + :
+ {provider.credentialProfiles.filter(profile => profile.authoritySchemes.includes('openbao') && credentials[profile.id]).map(profile => { + const status = credentials[profile.id]; + const action = `/v1/teams/${encodeURIComponent(team.id)}/services/${encodeURIComponent(connection.id)}/credentials/${encodeURIComponent(profile.id)}`; + return + {status.configured ? 'Configured in OpenBao' : 'Not configured'} +
+ + + {profile.fields.filter(field => field.sensitive).map(field => + + )} + +
+ + {status.configured && ['validate','delete'].map(operation =>
+ + + +
)} +
; + })} +
}
diff --git a/src/pages/app/services/index.astro b/src/pages/app/services/index.astro index ab9e80f0..48620320 100644 --- a/src/pages/app/services/index.astro +++ b/src/pages/app/services/index.astro @@ -14,9 +14,7 @@ const preferences = await api.accountPreferences().catch(() => ({ timeZone: 'UTC const providerFilter = Astro.url.searchParams.get('provider') ?? ''; const capabilityFilter = Astro.url.searchParams.get('capability') ?? ''; let services: any[] = []; -let vault: any = null; let unavailable = false; -let vaultUnavailable = false; const managementRoles = new Set(['team_owner', 'project_lead', 'service_admin']); const canManage = Boolean(team?.roles?.some((role: string) => managementRoles.has(role)) || team?.currentUserRole && managementRoles.has(team.currentUserRole)); if (team) { @@ -25,16 +23,9 @@ if (team) { } catch { unavailable = true; } - if (canManage) { - try { - vault = await api.request('GET', `/v1/teams/${encodeURIComponent(team.id)}/vault`); - } catch { - vaultUnavailable = true; - } - } } --- - + diff --git a/src/pages/app/services/vault.astro b/src/pages/app/services/vault.astro deleted file mode 100644 index 1663cad0..00000000 --- a/src/pages/app/services/vault.astro +++ /dev/null @@ -1,284 +0,0 @@ ---- -import Badge from '@treeseed/ui/components/astro/data/Badge.astro'; -import Timestamp from '@treeseed/ui/components/astro/data/Timestamp.astro'; -import Button from '@treeseed/ui/components/astro/forms/Button.astro'; -import FormActions from '@treeseed/ui/components/astro/forms/FormActions.astro'; -import Field from '@treeseed/ui/components/astro/forms/Field.astro'; -import PasswordSetupFields from '@treeseed/ui/components/astro/forms/fields/PasswordSetupFields.astro'; -import TextInput from '@treeseed/ui/components/astro/forms/TextInput.astro'; -import DisclosureList from '@treeseed/ui/components/astro/data/DisclosureList.astro'; -import DataBoundary from '@treeseed/ui/components/astro/patterns/DataBoundary.astro'; -import SetupProgress from '@treeseed/ui/components/astro/patterns/SetupProgress.astro'; -import EmptyState from '@treeseed/ui/components/astro/surface/EmptyState.astro'; -import Panel from '@treeseed/ui/components/astro/surface/Panel.astro'; -import SettingsTemplate from '@treeseed/ui/components/astro/templates/SettingsTemplate.astro'; -import TreeseedAppLayout from '../../../layouts/AppLayout.astro'; -import { ensureCsrfToken } from '../../../lib/auth/support/csrf'; -import { ApiClientFacade } from '../../../lib/market/api-client'; -import { serviceSections } from '../../../lib/services/navigation'; -import { loadAppContext } from '../../../view-models/app-access.js'; - -if (!Astro.locals.auth?.principal) return Astro.redirect(`/auth/sign-in?returnTo=${encodeURIComponent('/app/services/vault')}`); -const context = await loadAppContext(Astro); -const team = context.activeTeam; -const canManageVault = Boolean(team?.roles?.includes('team_owner') || team?.currentUserRole === 'team_owner'); -const api = new ApiClientFacade(Astro); -const preferences = await api.accountPreferences().catch(() => ({ timeZone: 'UTC' })); -let userVaultKey: any = null; -let vault: any = null; -let grantCandidates: any[] = []; -let vaultEnvelopes: any[] = []; -let unavailable = false; -let forbidden = false; -try { - userVaultKey = await api.request('GET', '/v1/users/me/vault-key'); - if (team) { - vault = await api.request('GET', `/v1/teams/${encodeURIComponent(team.id)}/vault`); - if (vault?.ownGrant && (team?.roles?.includes('team_owner') || team?.currentUserRole === 'team_owner')) { - [grantCandidates, vaultEnvelopes] = await Promise.all([ - api.request('GET', `/v1/teams/${encodeURIComponent(team.id)}/vault/grant-candidates`), - api.request('GET', `/v1/teams/${encodeURIComponent(team.id)}/vault/credential-envelopes`), - ]); - } - } -} catch (error) { - const status = Number((error as { status?: unknown })?.status); - if (status === 401 || status === 403 || (status === 404 && !canManageVault)) forbidden = true; - else unavailable = true; -} -const csrfToken = ensureCsrfToken(Astro); -const setupSteps = [ - { - id: 'personal', - label: 'Create personal access', - description: 'Your passphrase protects your administrator key and never leaves this browser.', - status: userVaultKey ? 'complete' : 'current', - knowledgePageId: 'vault.personal-key', - }, - { - id: 'team', - label: 'Initialize team custody', - description: 'A random team key is wrapped separately for each credential administrator.', - status: vault ? 'complete' : userVaultKey ? 'current' : 'blocked', - knowledgePageId: 'vault.team-custody', - }, - { - id: 'recovery', - label: 'Review recovery', - description: 'Know how access is restored—and when credentials must be reentered.', - status: vault ? 'current' : 'blocked', - knowledgePageId: 'vault.recovery', - }, -] as const; ---- - - - -
- {!team ? ( - - ) : forbidden ? ( - - ) : unavailable ? ( - -
-
- ) : ( - <> - {!canManageVault ? ( - - ) : null} -
- - -
- - {!userVaultKey ? ( - -
- - - - -
- ) : !vault ? ( - -
- Administrator key ready - Client-encrypted -
- {!canManageVault ? ( -

Only a team owner can initialize or change vault grants. Service administrators can manage credentials after an owner grants cryptographic access.

- ) : ( -
- - - - - - - -
- )} -
- ) : ( - <> - -
- Active - Key version {vault.activeKeyVersion} - {vault.ownGrant ? 'You have access' : 'Grant required'} -
-

Initialized . If every authorized administrator loses access, protected credentials must be reentered.

- -
- - -
- Administrator key ready - Personal key version {userVaultKey.version} -
-
- Change personal passphrase -

This re-encrypts your personal private key. Provider credentials are not decrypted or rewritten.

-
- - - - - - - - -
-
- {vault.grants.map((grant: any) => ( -
- {grant.userId === Astro.locals.auth?.principal?.id ? 'Your administrator grant' : `Administrator ${grant.userId}`} - Key version {grant.keyVersion} · {grant.status} - {canManageVault && vault.ownGrant && grant.status === 'active' && grant.userId !== Astro.locals.auth?.principal?.id ? ( -
- - -
- ) : null} -
- ))} -
- {canManageVault && vault.ownGrant && grantCandidates.length ? ( -
- Grant another administrator access -

The team key is opened and rewrapped only in this browser. The recipient uses a separate personal passphrase.

- {grantCandidates.map((candidate) => ( -
- - - - - - -

{candidate.displayName} · {candidate.roles.join(', ')}

- - - - -
- ))} -
- ) : null} - {canManageVault && vault.ownGrant ? ( -
- Rotate the team vault key -

Rewrap every credential key and administrator grant without decrypting or replacing provider credential values.

-
- - - - - - grant.status === 'active'))} /> - - - - -
-
- ) : null} -
- - {canManageVault ? ( - -
- - - - - - - - - - - - - -
-
- ) : null} - - )} - - )} -
-
-
- - diff --git a/src/routes.ts b/src/routes.ts index 2f6ef6e4..58200b01 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -55,7 +55,6 @@ export const ADMIN_ROUTES: readonly SiteRouteContribution[] = validateRouteCapab adminRoute('/app/teams/[teamId]/members', 'pages/app/teams/[teamId]/members.astro', { id: 'admin.team.members', description: 'Team membership, invitation, role, and removal operations.', guarantees: ['guarantee.team.membership.invite-team-members.017', 'guarantee.team.membership.change-member-role.019', 'guarantee.team.membership.remove-team-member.020'], knowledgePageIds: ['team.membership'] }), adminRoute('/app/services', 'pages/app/services/index.astro', { id: 'admin.services.collection', description: 'Active-team provider connections and capability availability.', guarantees: ['guarantee.service.connection.service-management-production-readiness.026'], knowledgePageIds: ['services.connections'] }), adminRoute('/app/services/new', 'pages/app/services/new.astro', { id: 'admin.services.create', description: 'Provider-first service connection wizard.', guarantees: ['guarantee.service.connection.connect-service.023'], knowledgePageIds: ['services.connect'] }), - adminRoute('/app/services/vault', 'pages/app/services/vault.astro', { id: 'admin.services.vault', description: 'Personal administrator key and team vault custody management.', guarantees: ['guarantee.service.vault.initialize-personal-vault.021', 'guarantee.service.vault.recover-vault-access.022'], knowledgePageIds: ['vault.overview'] }), adminRoute('/app/services/[connectionId]', 'pages/app/services/[connectionId].astro', { id: 'admin.services.detail', description: 'Provider connection, capabilities, encrypted credentials, activity, and settings.', guarantees: ['guarantee.service.connection.edit-service.024', 'guarantee.service.connection.disconnect-service.025'], knowledgePageIds: ['services.connection'] }), adminRoute('/app/feedback', 'pages/app/feedback/index.astro', { id: 'admin.feedback.collection', description: 'Platform-administrator feedback search, triage, and privacy-safe export.', accessPolicy: ['platform_admin'], guarantees: ['guarantee.admin.feedback.manage-feedback.030'], knowledgePageIds: ['feedback.administration'] }), adminRoute('/app/feedback/[feedbackId]', 'pages/app/feedback/[feedbackId].astro', { id: 'admin.feedback.detail', description: 'Private feedback context, attachment, timeline, and resolution workflow.', accessPolicy: ['platform_admin'], guarantees: ['guarantee.admin.feedback.manage-feedback.030'], knowledgePageIds: ['feedback.triage'] }), diff --git a/tests/contract/contextual-help/contextual-help-architecture.test.ts b/tests/contract/contextual-help/contextual-help-architecture.test.ts index 5fa17a30..4912c4f5 100644 --- a/tests/contract/contextual-help/contextual-help-architecture.test.ts +++ b/tests/contract/contextual-help/contextual-help-architecture.test.ts @@ -19,7 +19,7 @@ describe('Admin contextual help architecture', () => { const routedPages = [...routes.matchAll(/knowledgePageIds:\s*\['([^']+)'\]/gu)].map((match) => match[1]); expect(routedPages.length).toBeGreaterThanOrEqual(15); for (const id of routedPages) expect(id).toMatch(/^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/u); - for (const route of ['/app/account', '/app/teams', '/app/services', '/app/services/vault', '/team-invites/[token]/accept']) { + for (const route of ['/app/account', '/app/teams', '/app/services', '/team-invites/[token]/accept']) { const line = routes.split('\n').find((candidate) => candidate.includes(`adminRoute('${route}'`)); expect(line, route).toContain('knowledgePageIds'); } @@ -42,7 +42,6 @@ describe('Admin contextual help architecture', () => { 'src/pages/app/services/index.astro', 'src/pages/app/services/new.astro', 'src/pages/app/services/[connectionId].astro', - 'src/pages/app/services/vault.astro', ].map(read)).join('\n'); const literalIds = [...sources.matchAll(/knowledgePageId="([^"]+)"/gu)].map((match) => match[1]); expect(literalIds.length).toBeGreaterThan(20); @@ -50,15 +49,13 @@ describe('Admin contextual help architecture', () => { expect(read('src/lib/help/context.ts')).toContain('/v1/knowledge/pages/{pageId}'); }); - it('makes vault passphrase reuse explicit and uses shared form spacing', () => { - const vault = read('src/pages/app/services/vault.astro'); - expect(vault).toContain('Re-enter the personal vault passphrase you created in step 1.'); - expect(vault).toContain('This does not create a second or shared passphrase.'); - expect(vault).toContain('label="Step 1 personal vault passphrase"'); - expect(vault.match(/knowledgePageId="vault\.(?:personal-key|team-custody|recovery)"/gu)?.length).toBeGreaterThanOrEqual(3); - expect(vault).toContain('class="ts-form-stack"'); - expect(vault).toContain(' { + const page=read('src/pages/app/services/[connectionId].astro'); + expect(page).toContain('Core OpenBao stores encrypted team credentials.'); + expect(page).toContain('knowledgePageId="services.credentials"'); + expect(page).toContain('class="ts-form-stack"'); + expect(page).not.toContain('passphrase'); + }); it('has one shared dialog transport and no legacy help implementation', () => { const uiPackage = readDependency('@treeseed/ui', 'package.json'); diff --git a/tests/contract/package/admin-package.test.ts b/tests/contract/package/admin-package.test.ts index cb6e4d0f..7484cc68 100644 --- a/tests/contract/package/admin-package.test.ts +++ b/tests/contract/package/admin-package.test.ts @@ -28,7 +28,6 @@ const EXPECTED_ROUTES = [ '/app/feedback/[feedbackId]', '/app/services', '/app/services/new', - '/app/services/vault', '/app/services/[connectionId]', '/app/knowledge', '/app/knowledge/packs/[buildId]/download', diff --git a/tests/contract/service-management/service-management-architecture.test.ts b/tests/contract/service-management/service-management-architecture.test.ts index f5f8f8be..14cd0f94 100644 --- a/tests/contract/service-management/service-management-architecture.test.ts +++ b/tests/contract/service-management/service-management-architecture.test.ts @@ -9,7 +9,7 @@ const readDependency = (name: string, path: string) => readFileSync(resolve(root describe('service management architecture', () => { it('owns one provider-first route family with no legacy host route', () => { const routes = read('src/routes.ts'); - for (const route of ['/app/services', '/app/services/new', '/app/services/vault', '/app/services/[connectionId]']) { + for (const route of ['/app/services', '/app/services/new', '/app/services/[connectionId]']) { expect(routes).toContain(`'${route}'`); } expect(routes).not.toContain('/app/hosts'); @@ -31,11 +31,9 @@ describe('service management architecture', () => { read('src/pages/app/services/index.astro'), read('src/pages/app/services/new.astro'), read('src/pages/app/services/[connectionId].astro'), - read('src/pages/app/services/vault.astro'), ].join('\n'); expect(createSurface).toContain('ProviderCard'); expect(pages).toContain('@treeseed/ui/components/astro/templates/SettingsTemplate.astro'); - expect(pages).toContain('@treeseed/ui/components/astro/patterns/SetupProgress.astro'); expect(pages).toContain('data-ts-submit="enhanced"'); expect(pages).not.toMatch(/\bfetch\s*\(/u); expect(pages).not.toContain('window.alert'); @@ -43,10 +41,10 @@ describe('service management architecture', () => { it('uses one canonical routed tab model across collection, setup, detail, and vault pages', () => { const navigation = read('src/lib/services/navigation.ts'); - for (const label of ['Connections', 'Vault']) { + for (const label of ['Connections']) { expect(navigation).toContain(`'${label}'`); } - expect(navigation.match(/label: '/gu)).toHaveLength(2); + expect(navigation.match(/label: '/gu)).toHaveLength(1); expect(navigation).not.toContain('Connect service'); const detail = read('src/pages/app/services/[connectionId].astro'); expect(detail).not.toContain('mode="panels"'); @@ -54,27 +52,17 @@ describe('service management architecture', () => { expect(detail).not.toContain('searchParams.get(\'tab\')'); }); - it('guides vault setup through shared progress and explanation primitives', () => { - const vault = read('src/pages/app/services/vault.astro'); - expect(vault).toContain('Secure credential setup'); - expect(vault).toContain('Credentials stay under your control'); - expect(vault).toContain('TreeSeed never retains'); - expect(vault).toContain('Recovery and custody rules'); - expect(vault).toContain('Step 1 of 3'); - expect(vault).toContain('Step 2 of 3'); - expect(vault).toContain('Step 3 of 3'); - }); + it('uses core managed custody without a separate vault setup route', () => { + const detail=read('src/pages/app/services/[connectionId].astro'); + expect(detail).toContain('Core OpenBao');expect(detail).toContain('managed-credentials'); + expect(read('src/routes.ts')).not.toContain('/app/services/vault'); + }); - it('keeps secret operations in browser adapters and sends only envelopes', () => { - const adapters = read('src/lib/services/form-adapters.ts'); - expect(adapters).toContain('encryptServiceVaultPrivateKey'); - expect(adapters).toContain('encryptServiceCredential'); - expect(adapters).toContain('createTeamVaultGrant'); - expect(adapters).toContain('openTeamVaultGrant'); - expect(adapters).not.toContain('localStorage'); - expect(adapters).not.toContain('sessionStorage'); - expect(adapters).not.toContain('document.cookie'); - }); + it('sends credentials through authenticated enhanced forms without persistent browser custody', () => { + const adapters=read('src/lib/services/form-adapters.ts'); + expect(adapters).toContain('Idempotency-Key');expect(adapters).toContain('x-treeseed-csrf');expect(adapters).toContain('expectedVersion'); + for(const retired of ['encryptServiceCredential','createTeamVaultGrant','localStorage','sessionStorage','document.cookie'])expect(adapters).not.toContain(retired); + }); it('supports portable topology references and isolated R2 state metadata', () => { const createSurface = readDependency('@treeseed/ui', 'dist/astro/service/workspace/ServiceConnectionCreateSurface.astro'); @@ -88,7 +76,7 @@ describe('service management architecture', () => { }); it('renders service activity in the persisted user timezone', () => { - for (const page of ['src/pages/app/services/index.astro', 'src/pages/app/services/[connectionId].astro', 'src/pages/app/services/vault.astro']) { + for (const page of ['src/pages/app/services/index.astro', 'src/pages/app/services/[connectionId].astro']) { const source = read(page); expect(source).toContain('api.accountPreferences()'); expect(source).toContain('timeZone={preferences.timeZone}'); diff --git a/tests/unit/services/managed-credentials.test.ts b/tests/unit/services/managed-credentials.test.ts new file mode 100644 index 00000000..aa76e8ab --- /dev/null +++ b/tests/unit/services/managed-credentials.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest'; +const adapters = vi.hoisted(() => new Map()); +vi.mock('@treeseed/ui/forms/client', () => ({registerFormAdapter: (name:string, adapter:any) => { + adapters.set(name,adapter); return () => adapters.delete(name); +}})); +import { registerServiceFormAdapters } from '../../../src/lib/services/form-adapters'; +describe('managed credentials form boundary', () => { + it('sends CAS credentials to the same-origin API, clears inputs and retains no browser vault keys', () => { + const dispose=registerServiceFormAdapters(), formData=new FormData(); + formData.set('expectedVersion','3'); formData.set('csrfToken','synthetic-csrf'); + formData.set('secret.apiToken',' synthetic-token '); formData.set('secret.optional',''); + const input={value:' synthetic-token '}; + const request=adapters.get('managed-credentials').buildRequest({formData,form:{ + action:'https://local.example/v1/teams/team/services/connection/credentials/profile', + dataset:{tsMethod:'PUT'},querySelectorAll:()=>[input], + }}); + expect(request.init.method).toBe('PUT'); + expect(JSON.parse(request.init.body)).toEqual({expectedVersion:3,values:{apiToken:' synthetic-token '}}); + expect(request.init.headers['x-treeseed-csrf']).toBe('synthetic-csrf'); + expect(request.init.headers['Idempotency-Key']).toMatch(/^[a-f0-9-]{36}$/); + expect(input.value).toBe(''); dispose(); + }); + it('delete and validate send version only', () => { + const dispose=registerServiceFormAdapters(); + for(const method of ['DELETE','POST']) { + const formData=new FormData(); formData.set('expectedVersion','4'); formData.set('secret.apiToken','unused'); + const request=adapters.get('managed-credentials').buildRequest({formData,form:{action:'/credentials',dataset:{tsMethod:method},querySelectorAll:()=>[]}}); + expect(JSON.parse(request.init.body)).toEqual({expectedVersion:4}); + } + dispose(); + }); +}); From 417df0d0e4acca05020652c9d6b71f671158c167 Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Sat, 5 Sep 2026 12:21:41 -0400 Subject: [PATCH 2/5] fix(services): consume canonical cursor collections before rendering (#76) --- package-lock.json | 4 +- package.json | 2 +- src/lib/services/collections.ts | 25 ++++++++++++ src/pages/app/services/[connectionId].astro | 3 +- src/pages/app/services/index.astro | 3 +- tests/unit/services/collections.test.ts | 45 +++++++++++++++++++++ 6 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 src/lib/services/collections.ts create mode 100644 tests/unit/services/collections.test.ts diff --git a/package-lock.json b/package-lock.json index 0385ca56..c98d840a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@treeseed/admin", - "version": "0.12.59-rc.27", + "version": "0.12.59-rc.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@treeseed/admin", - "version": "0.12.59-rc.27", + "version": "0.12.59-rc.28", "license": "Apache-2.0", "dependencies": { "@astrojs/starlight": "0.37.6", diff --git a/package.json b/package.json index f04bd05d..a6950505 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@treeseed/admin", - "version": "0.12.59-rc.27", + "version": "0.12.59-rc.28", "description": "Treeseed administration portal framework built on @treeseed/core and @treeseed/ui.", "license": "Apache-2.0", "repository": { diff --git a/src/lib/services/collections.ts b/src/lib/services/collections.ts new file mode 100644 index 00000000..e0b66f9e --- /dev/null +++ b/src/lib/services/collections.ts @@ -0,0 +1,25 @@ +import { controlPlaneOperation } from '@treeseed/sdk/operator-contracts'; +import type { ApiClientFacade } from '../market/api-client'; + +type ServiceCollection = 'services.connections.list' | 'services.credential.authorities.list'; + +/** Service reads return cursor pages, never bare arrays. Reject incomplete reads. */ +export async function loadServiceItems(api: { invoke: OmitThisParameter }, operationId: ServiceCollection, + path: { teamId: string; connectionId?: string }): Promise { + const operation = controlPlaneOperation(operationId); + const items: any[] = [], seen = new Set(); + let cursor: string | undefined; + for (let count = 0; count < 100; count++) { + const page = await api.invoke(operation, { path, query: cursor ? { cursor } : {}, body: undefined }); + if (!page || typeof page !== 'object' || !Array.isArray(page.items) + || page.items.some((item: unknown) => !item || typeof item !== 'object' || Array.isArray(item)) + || !(page.cursor === null || typeof page.cursor === 'string' && page.cursor.length > 0)) { + throw new Error('Invalid service collection response.'); + } + items.push(...page.items); + if (page.cursor === null) return items; + if (seen.has(page.cursor)) throw new Error('Repeated service collection cursor.'); + seen.add(page.cursor); cursor = page.cursor; + } + throw new Error('Service collection pagination limit exceeded.'); +} diff --git a/src/pages/app/services/[connectionId].astro b/src/pages/app/services/[connectionId].astro index 3858ce68..57d3b4f0 100644 --- a/src/pages/app/services/[connectionId].astro +++ b/src/pages/app/services/[connectionId].astro @@ -17,6 +17,7 @@ import TreeseedAppLayout from '../../../layouts/AppLayout.astro'; import { ensureCsrfToken } from '../../../lib/auth/support/csrf'; import { ApiClientFacade } from '../../../lib/market/api-client'; import { serviceSections } from '../../../lib/services/navigation'; +import { loadServiceItems } from '../../../lib/services/collections'; import { loadAppContext } from '../../../view-models/app-access.js'; if (!Astro.locals.auth?.principal) return Astro.redirect(`/auth/sign-in?returnTo=${encodeURIComponent(Astro.url.pathname)}`); @@ -40,8 +41,8 @@ if (team) { else unavailable = true; } if (connection && canManage) { - authorities = await api.request('GET', `/v1/teams/${encodeURIComponent(team.id)}/services/${encodeURIComponent(connectionId)}/credential-authorities`).catch(() => []); try { + authorities = await loadServiceItems(api, 'services.credential.authorities.list', { teamId: team.id, connectionId }); const profiles = getServiceProviderDefinition(connection.providerId)?.credentialProfiles.filter(profile => profile.authoritySchemes.includes('openbao') && connection.capabilities.some((binding: any) => binding.status === 'configured' && binding.credentialProfileId === profile.id)) ?? []; await Promise.all(profiles.map(async profile => { diff --git a/src/pages/app/services/index.astro b/src/pages/app/services/index.astro index 48620320..c8a7fb1b 100644 --- a/src/pages/app/services/index.astro +++ b/src/pages/app/services/index.astro @@ -4,6 +4,7 @@ import ServiceConnectionWorkspace from '@treeseed/ui/components/astro/service/wo import TreeseedAppLayout from '../../../layouts/AppLayout.astro'; import { ApiClientFacade } from '../../../lib/market/api-client'; import { serviceSections } from '../../../lib/services/navigation'; +import { loadServiceItems } from '../../../lib/services/collections'; import { loadAppContext } from '../../../view-models/app-access.js'; if (!Astro.locals.auth?.principal) return Astro.redirect(`/auth/sign-in?returnTo=${encodeURIComponent('/app/services')}`); @@ -19,7 +20,7 @@ const managementRoles = new Set(['team_owner', 'project_lead', 'service_admin']) const canManage = Boolean(team?.roles?.some((role: string) => managementRoles.has(role)) || team?.currentUserRole && managementRoles.has(team.currentUserRole)); if (team) { try { - services = await api.request('GET', `/v1/teams/${encodeURIComponent(team.id)}/services`); + services = await loadServiceItems(api, 'services.connections.list', { teamId: team.id }); } catch { unavailable = true; } diff --git a/tests/unit/services/collections.test.ts b/tests/unit/services/collections.test.ts new file mode 100644 index 00000000..1d24bc60 --- /dev/null +++ b/tests/unit/services/collections.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it, vi } from 'vitest'; +import { loadServiceItems } from '../../../src/lib/services/collections'; + +describe('Services cursor page rendering boundary', () => { + for (const operationId of ['services.connections.list', 'services.credential.authorities.list'] as const) { + it(`${operationId}: supplies item arrays, retaining scope across pages`, async () => { + const first = { id: 'one', credentialProfileId: 'github-repository-app', status: 'ready' }; + const second = { id: 'two' }; + const invoke = vi.fn().mockResolvedValueOnce({ items: [first], cursor: 'next' }) + .mockResolvedValueOnce({ items: [second], cursor: null }); + const path = operationId === 'services.connections.list' ? { teamId: 'team' } : { teamId: 'team', connectionId: 'connection' }; + const items = await loadServiceItems({ invoke }, operationId, path); + expect(items.filter(item => item.id === 'one')).toEqual([first]); + expect(items.find(item => item.credentialProfileId === 'github-repository-app')?.status).toBe('ready'); + expect(invoke.mock.calls.map(([binding, input]) => [binding.descriptor.operationId, input])).toEqual([ + [operationId, { path, query: {}, body: undefined }], + [operationId, { path, query: { cursor: 'next' }, body: undefined }], + ]); + expect(await loadServiceItems({ invoke: vi.fn().mockResolvedValue({ items: [], cursor: null }) }, operationId, path)).toEqual([]); + }); + } + it('rejects legacy arrays, malformed pages and cursor loops instead of returning partial or empty success', async () => { + for (const page of [[], null, {}, { items: {}, cursor: null }, { items: [null], cursor: null }, { items: [], cursor: 1 }]) { + await expect(loadServiceItems({ invoke: vi.fn().mockResolvedValue(page) }, 'services.connections.list', { teamId: 'team' })).rejects.toThrow('Invalid service collection'); + } + const invoke = vi.fn().mockResolvedValue({ items: [], cursor: 'same' }); + await expect(loadServiceItems({ invoke }, 'services.connections.list', { teamId: 'team' })).rejects.toThrow('Repeated'); + expect(invoke).toHaveBeenCalledTimes(2); + }); + it('bounds pagination and propagates unavailable authority without hiding it as empty', async () => { + const invoke = vi.fn().mockImplementation(async () => ({ items: [], cursor: String(invoke.mock.calls.length) })); + await expect(loadServiceItems({ invoke }, 'services.connections.list', { teamId: 'team' })).rejects.toThrow('limit'); + expect(invoke).toHaveBeenCalledTimes(100); + await expect(loadServiceItems({ invoke: vi.fn().mockRejectedValue(new Error('denied')) }, 'services.credential.authorities.list', { teamId: 'team', connectionId: 'connection' })).rejects.toThrow('denied'); + }); + it('wires both rendered routes to the tested loader inside their unavailable-state boundary', () => { + for (const [file, operation] of [['index.astro', 'services.connections.list'], ['[connectionId].astro', 'services.credential.authorities.list']]) { + const source = readFileSync(`src/pages/app/services/${file}`, 'utf8'); + expect(source).toContain(`await loadServiceItems(api, '${operation}'`); + expect(source).not.toContain('api.request'); + expect(source.indexOf('try {')).toBeLessThan(source.indexOf('await loadServiceItems')); + } + }); +}); From 9f0ff9ab2c600cd47766dd7bea8fade25e5108a7 Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Sat, 5 Sep 2026 23:57:35 -0400 Subject: [PATCH 3/5] feat(services): adopt published responsive discovery controls (#85) --- package-lock.json | 12 ++++++------ package.json | 4 ++-- src/pages/app/services/index.astro | 4 ++-- .../service-management-architecture.test.ts | 4 +++- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index ddd5b1ef..a5cb3245 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "@treeseed/admin", - "version": "0.12.59-rc.31", + "version": "0.12.59-rc.32", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@treeseed/admin", - "version": "0.12.59-rc.31", + "version": "0.12.59-rc.32", "license": "Apache-2.0", "dependencies": { "@astrojs/starlight": "0.37.6", "@react-email/render": "^2.0.8", "@treeseed/core": ">=0.12.60-rc.1 <0.14.0", "@treeseed/sdk": "0.13.0-rc.87", - "@treeseed/ui": "0.12.18-rc.16", + "@treeseed/ui": "0.12.18-rc.17", "react": "^19.2.5", "react-dom": "^19.2.6" }, @@ -6986,9 +6986,9 @@ } }, "node_modules/@treeseed/ui": { - "version": "0.12.18-rc.16", - "resolved": "https://registry.npmjs.org/@treeseed/ui/-/ui-0.12.18-rc.16.tgz", - "integrity": "sha512-+3xkv6qS482d1rvxSenKy2sM/UYey5gTpXS5RLiiFx4f+/PA7z7e1kL/+VUPvSfBEeMQMpUpvDgLHOX9xHI8lg==", + "version": "0.12.18-rc.17", + "resolved": "https://registry.npmjs.org/@treeseed/ui/-/ui-0.12.18-rc.17.tgz", + "integrity": "sha512-FGKjsIs2jyzJSq+8WV1/paCtBW9OOCB4NTAYILuEELfxciJVL/dKCL7CiT3t2Ylt0alQuvWeobm5lKaBk9G0Lw==", "license": "Apache-2.0", "dependencies": { "@codemirror/lang-markdown": "^6.5.0", diff --git a/package.json b/package.json index 9e5bbe5d..d4078e48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@treeseed/admin", - "version": "0.12.59-rc.31", + "version": "0.12.59-rc.32", "description": "Treeseed administration portal framework built on @treeseed/core and @treeseed/ui.", "license": "Apache-2.0", "repository": { @@ -63,7 +63,7 @@ "@react-email/render": "^2.0.8", "@treeseed/core": ">=0.12.60-rc.1 <0.14.0", "@treeseed/sdk": "0.13.0-rc.87", - "@treeseed/ui": "0.12.18-rc.16", + "@treeseed/ui": "0.12.18-rc.17", "react": "^19.2.5", "react-dom": "^19.2.6" }, diff --git a/src/pages/app/services/index.astro b/src/pages/app/services/index.astro index c8a7fb1b..1404b43b 100644 --- a/src/pages/app/services/index.astro +++ b/src/pages/app/services/index.astro @@ -13,7 +13,7 @@ const team = context.activeTeam; const api = new ApiClientFacade(Astro); const preferences = await api.accountPreferences().catch(() => ({ timeZone: 'UTC' })); const providerFilter = Astro.url.searchParams.get('provider') ?? ''; -const capabilityFilter = Astro.url.searchParams.get('capability') ?? ''; +const capabilityFilters = Astro.url.searchParams.getAll('capability').filter(Boolean); let services: any[] = []; let unavailable = false; const managementRoles = new Set(['team_owner', 'project_lead', 'service_admin']); @@ -28,5 +28,5 @@ if (team) { --- - + diff --git a/tests/contract/service-management/service-management-architecture.test.ts b/tests/contract/service-management/service-management-architecture.test.ts index bc8f799d..cf318089 100644 --- a/tests/contract/service-management/service-management-architecture.test.ts +++ b/tests/contract/service-management/service-management-architecture.test.ts @@ -22,7 +22,9 @@ describe('service management architecture', () => { expect(collectionPage).not.toContain(component); } const workspace = readDependency('@treeseed/ui', 'dist/astro/service/workspace/ServiceConnectionWorkspace.astro'); - for (const component of ['ProviderCard', 'SettingsTemplate', 'SemanticCollectionSurface']) expect(workspace).toContain(component); + for (const component of ['ProviderCard', 'SettingsTemplate', 'SemanticCollectionSurface', 'ItemPicker', 'CollapsibleMultiSelect']) expect(workspace).toContain(component); + expect(collectionPage).toContain("searchParams.getAll('capability')"); + expect(collectionPage).toContain('capabilityFilters'); expect(workspace).not.toContain('Managed OpenBao'); const createPage = read('src/pages/app/services/new.astro'); expect(createPage).toContain('@treeseed/ui/components/astro/service/workspace/ServiceConnectionCreateSurface.astro'); From 2d4541e1d5d7e7d9d81c7c19e8d05e3e7350e6f0 Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Sun, 6 Sep 2026 00:24:00 -0400 Subject: [PATCH 4/5] fix(services): retain form adapters across navigation (#87) --- package-lock.json | 4 +-- package.json | 2 +- src/lib/services/form-lifecycle.ts | 22 +++++++++++++ src/pages/app/services/[connectionId].astro | 13 ++------ src/pages/app/services/new.astro | 5 ++- tests/unit/services/form-adapters.test.ts | 1 + tests/unit/services/form-lifecycle.test.ts | 35 +++++++++++++++++++++ 7 files changed, 65 insertions(+), 17 deletions(-) create mode 100644 src/lib/services/form-lifecycle.ts create mode 100644 tests/unit/services/form-lifecycle.test.ts diff --git a/package-lock.json b/package-lock.json index a5cb3245..55a38405 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@treeseed/admin", - "version": "0.12.59-rc.32", + "version": "0.12.59-rc.33", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@treeseed/admin", - "version": "0.12.59-rc.32", + "version": "0.12.59-rc.33", "license": "Apache-2.0", "dependencies": { "@astrojs/starlight": "0.37.6", diff --git a/package.json b/package.json index d4078e48..fa4feb05 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@treeseed/admin", - "version": "0.12.59-rc.32", + "version": "0.12.59-rc.33", "description": "Treeseed administration portal framework built on @treeseed/core and @treeseed/ui.", "license": "Apache-2.0", "repository": { diff --git a/src/lib/services/form-lifecycle.ts b/src/lib/services/form-lifecycle.ts new file mode 100644 index 00000000..4f79e27a --- /dev/null +++ b/src/lib/services/form-lifecycle.ts @@ -0,0 +1,22 @@ +import { registerServiceFormAdapters } from './form-adapters'; + +const initialized = new WeakSet(); + +/** Adapters are stateless and belong to the document, not a routed page. */ +export function initializeServiceForms(root: Document = document) { + if (initialized.has(root)) return; + initialized.add(root); + registerServiceFormAdapters(); + root.addEventListener('treeseed:form-success', event => { + const form = event.target; + if (!(form instanceof HTMLFormElement)) return; + if (form.hasAttribute('data-service-disconnect')) { + window.location.assign('/app/services'); + } else if (form.matches('[data-service-settings], [data-service-credentials]')) { + const url = new URL(window.location.pathname, window.location.origin); + if (form.hasAttribute('data-service-settings')) url.searchParams.set('edit', 'true'); + url.searchParams.set('tsToastSuccess', (event as CustomEvent).detail?.message ?? 'Saved.'); + window.location.assign(url.href); + } + }); +} diff --git a/src/pages/app/services/[connectionId].astro b/src/pages/app/services/[connectionId].astro index cb00b0f5..3386401f 100644 --- a/src/pages/app/services/[connectionId].astro +++ b/src/pages/app/services/[connectionId].astro @@ -147,15 +147,6 @@ const profileTitle = (id: string, fallback: string) => id === 'github-repository } diff --git a/src/pages/app/services/new.astro b/src/pages/app/services/new.astro index 291c082a..f20a0a16 100644 --- a/src/pages/app/services/new.astro +++ b/src/pages/app/services/new.astro @@ -21,7 +21,6 @@ const canManage = Boolean(team?.roles?.some((role: string) => managementRoles.ha diff --git a/tests/unit/services/form-adapters.test.ts b/tests/unit/services/form-adapters.test.ts index a19cb800..89f038b7 100644 --- a/tests/unit/services/form-adapters.test.ts +++ b/tests/unit/services/form-adapters.test.ts @@ -35,6 +35,7 @@ describe('guided service forms', () => { const built = adapter.buildRequest(ctx); let version = 3; const upstream = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { + expect(new Headers(init?.headers).get('Idempotency-Key')).toBe(new Headers(built.init.headers).get('Idempotency-Key')); if (new Headers(init?.headers).get('If-Match') !== String(version)) return Response.json({detail: 'The service connection changed after it was inspected.'}, {status: 412}); version++; diff --git a/tests/unit/services/form-lifecycle.test.ts b/tests/unit/services/form-lifecycle.test.ts new file mode 100644 index 00000000..bfa8d10a --- /dev/null +++ b/tests/unit/services/form-lifecycle.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest'; +const { adapters } = vi.hoisted(() => ({ adapters: new Map() })); +vi.mock('@treeseed/ui/forms/client', () => ({ registerFormAdapter(name: string, adapter: any) { + adapters.set(name, adapter); return () => adapters.delete(name); +} })); +import { initializeServiceForms } from '../../../src/lib/services/form-lifecycle'; + +describe('service form document lifetime', () => { + it('retains Cloudflare request contracts after swaps and repeat visits without duplicate listeners', () => { + const root = new EventTarget() as Document; + const listeners = vi.spyOn(root, 'addEventListener'); + initializeServiceForms(root); + const adapter = adapters.get('service-connection'); + for (let visit = 0; visit < 3; visit++) { + root.dispatchEvent(new Event('astro:before-swap')); + root.dispatchEvent(new Event('astro:page-load')); + initializeServiceForms(root); + expect(adapters.get('service-connection')).toBe(adapter); + for (const method of ['', 'PUT']) { + const formData = new FormData(); + for (const [key, value] of Object.entries({ providerId: 'cloudflare', displayName: 'storage', + capabilities: 'object-storage', 'config.accountId': 'fixture-account', version: '2', csrfToken: 'fixture' })) + formData.set(key, value); + const request = adapter.buildRequest({ formData, form: { action: '/v1/teams/test/services', dataset: { tsMethod: method } } }); + const headers = new Headers(request.init.headers); + expect(headers.get('Idempotency-Key')).toMatch(/^[0-9a-f-]{36}$/); + expect(headers.get('If-Match')).toBe(method ? '2' : null); + expect(headers.get('x-treeseed-csrf')).toBe('fixture'); + expect(JSON.parse(request.init.body)).toMatchObject({ nonSecretConfig: { accountId: 'fixture-account' }, + capabilities: [{ capabilityType: 'object-storage' }] }); + } + } + expect(listeners.mock.calls.filter(([name]) => name === 'treeseed:form-success')).toHaveLength(1); + }); +}); From 8279938613572acda2b2661ff33955194586166d Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Mon, 7 Sep 2026 13:02:27 -0400 Subject: [PATCH 5/5] fix(help): ship UI rc19 envelope and retry corrections --- package-lock.json | 13 ++++++------- package.json | 4 ++-- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 017f839e..8228e43a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "@treeseed/admin", - "version": "0.12.59-rc.34", + "version": "0.12.59-rc.35", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@treeseed/admin", - "version": "0.12.59-rc.34", + "version": "0.12.59-rc.35", "license": "Apache-2.0", "dependencies": { "@astrojs/starlight": "0.37.6", "@react-email/render": "^2.0.8", "@treeseed/core": ">=0.12.60-rc.1 <0.14.0", "@treeseed/sdk": "0.13.0-rc.92", - "@treeseed/ui": "0.12.18-rc.18", + "@treeseed/ui": "0.12.18-rc.19", "react": "^19.2.5", "react-dom": "^19.2.6" }, @@ -6986,10 +6986,9 @@ } }, "node_modules/@treeseed/ui": { - "version": "0.12.18-rc.18", - "resolved": "https://registry.npmjs.org/@treeseed/ui/-/ui-0.12.18-rc.18.tgz", - "integrity": "sha512-yk6xe55axyll92JhCnmxqYY1cYpvm7veONW3hZRJDVe1Emf1D264kVm8a5G78F4ktmLZH5bPjXhDrdRnYdekOA==", - "license": "Apache-2.0", + "version": "0.12.18-rc.19", + "resolved": "https://registry.npmjs.org/@treeseed/ui/-/ui-0.12.18-rc.19.tgz", + "integrity": "sha512-ZO5Deqn/ZFeqIjMEF8ZBsj/XlN1v8RNT/QdPCd9R5Ox19b241OONRxIllL2raqr0ye7mswzF81nIXp+TD1599Q==", "dependencies": { "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.3", diff --git a/package.json b/package.json index 9715bc7e..684b926f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@treeseed/admin", - "version": "0.12.59-rc.34", + "version": "0.12.59-rc.35", "description": "Treeseed administration portal framework built on @treeseed/core and @treeseed/ui.", "license": "Apache-2.0", "repository": { @@ -63,7 +63,7 @@ "@react-email/render": "^2.0.8", "@treeseed/core": ">=0.12.60-rc.1 <0.14.0", "@treeseed/sdk": "0.13.0-rc.92", - "@treeseed/ui": "0.12.18-rc.18", + "@treeseed/ui": "0.12.18-rc.19", "react": "^19.2.5", "react-dom": "^19.2.6" },