From f43736d94ab9e94509e2d834fc923f8e1c370bd9 Mon Sep 17 00:00:00 2001 From: jarek Date: Thu, 13 Aug 2026 10:20:29 +0200 Subject: [PATCH] stack secrets from external providers (1Password, HashiCorp Vault, Infisical and Doppler) --- drizzle-pg/0010_add_secret_providers.sql | 1 + drizzle/0010_add_secret_providers.sql | 3 +- .../components/provider-icons/Doppler.svelte | 10 ++ .../provider-icons/HashiCorpVault.svelte | 7 + .../provider-icons/Infisical.svelte | 7 + .../provider-icons/OnePassword.svelte | 7 + src/lib/components/provider-icons/index.ts | 23 +++ src/lib/server/db.ts | 62 ++++++- src/lib/server/db/schema/index.ts | 3 + src/lib/server/db/schema/pg-schema.ts | 3 + src/lib/server/encryption.ts | 12 +- src/lib/server/secretproviders/connect.ts | 15 +- src/lib/server/secretproviders/doppler.ts | 125 +++++++++++++ src/lib/server/secretproviders/index.ts | 4 +- src/lib/server/secretproviders/infisical.ts | 18 +- src/lib/server/secretproviders/shared.ts | 113 +++++++++++- src/lib/server/secretproviders/vault.ts | 23 ++- src/lib/server/stack-secret-keys.ts | 30 ++++ src/lib/server/url-safety.ts | 40 ++++- src/routes/api/git/stacks/+server.ts | 11 ++ src/routes/api/git/stacks/[id]/+server.ts | 11 ++ .../api/secret-providers/[id]/+server.ts | 13 +- src/routes/api/stacks/+server.ts | 11 ++ src/routes/settings/+page.svelte | 4 + .../settings/secrets/ProviderModal.svelte | 167 ++++++++++++------ src/routes/settings/secrets/SecretsTab.svelte | 34 ++-- src/routes/stacks/GitStackModal.svelte | 5 +- src/routes/stacks/StackModal.svelte | 5 +- 28 files changed, 667 insertions(+), 100 deletions(-) create mode 100644 src/lib/components/provider-icons/Doppler.svelte create mode 100644 src/lib/components/provider-icons/HashiCorpVault.svelte create mode 100644 src/lib/components/provider-icons/Infisical.svelte create mode 100644 src/lib/components/provider-icons/OnePassword.svelte create mode 100644 src/lib/components/provider-icons/index.ts create mode 100644 src/lib/server/secretproviders/doppler.ts create mode 100644 src/lib/server/stack-secret-keys.ts diff --git a/drizzle-pg/0010_add_secret_providers.sql b/drizzle-pg/0010_add_secret_providers.sql index 5e3ad6f..a80d752 100644 --- a/drizzle-pg/0010_add_secret_providers.sql +++ b/drizzle-pg/0010_add_secret_providers.sql @@ -9,4 +9,5 @@ CREATE TABLE "secret_providers" ( ); --> statement-breakpoint ALTER TABLE "stack_sources" ADD COLUMN "secret_provider_id" integer;--> statement-breakpoint +ALTER TABLE "stack_sources" ADD COLUMN "injected_secret_keys" text;--> statement-breakpoint ALTER TABLE "stack_sources" ADD CONSTRAINT "stack_sources_secret_provider_id_secret_providers_id_fk" FOREIGN KEY ("secret_provider_id") REFERENCES "public"."secret_providers"("id") ON DELETE set null ON UPDATE no action; diff --git a/drizzle/0010_add_secret_providers.sql b/drizzle/0010_add_secret_providers.sql index 15f7a11..11b45a2 100644 --- a/drizzle/0010_add_secret_providers.sql +++ b/drizzle/0010_add_secret_providers.sql @@ -8,4 +8,5 @@ CREATE TABLE `secret_providers` ( ); --> statement-breakpoint CREATE UNIQUE INDEX `secret_providers_name_unique` ON `secret_providers` (`name`);--> statement-breakpoint -ALTER TABLE `stack_sources` ADD `secret_provider_id` integer REFERENCES secret_providers(id) ON DELETE SET NULL; +ALTER TABLE `stack_sources` ADD `secret_provider_id` integer REFERENCES secret_providers(id) ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE `stack_sources` ADD `injected_secret_keys` text; diff --git a/src/lib/components/provider-icons/Doppler.svelte b/src/lib/components/provider-icons/Doppler.svelte new file mode 100644 index 0000000..5141878 --- /dev/null +++ b/src/lib/components/provider-icons/Doppler.svelte @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/src/lib/components/provider-icons/HashiCorpVault.svelte b/src/lib/components/provider-icons/HashiCorpVault.svelte new file mode 100644 index 0000000..78adc3d --- /dev/null +++ b/src/lib/components/provider-icons/HashiCorpVault.svelte @@ -0,0 +1,7 @@ + + + + + diff --git a/src/lib/components/provider-icons/Infisical.svelte b/src/lib/components/provider-icons/Infisical.svelte new file mode 100644 index 0000000..316997c --- /dev/null +++ b/src/lib/components/provider-icons/Infisical.svelte @@ -0,0 +1,7 @@ + + + + + diff --git a/src/lib/components/provider-icons/OnePassword.svelte b/src/lib/components/provider-icons/OnePassword.svelte new file mode 100644 index 0000000..c26bb89 --- /dev/null +++ b/src/lib/components/provider-icons/OnePassword.svelte @@ -0,0 +1,7 @@ + + + + + diff --git a/src/lib/components/provider-icons/index.ts b/src/lib/components/provider-icons/index.ts new file mode 100644 index 0000000..86c27f2 --- /dev/null +++ b/src/lib/components/provider-icons/index.ts @@ -0,0 +1,23 @@ +import type { Component } from 'svelte'; +import { KeyRound } from 'lucide-svelte'; +import OnePassword from './OnePassword.svelte'; +import Infisical from './Infisical.svelte'; +import HashiCorpVault from './HashiCorpVault.svelte'; +import Doppler from './Doppler.svelte'; + +/** Brand icon for a secret-provider type. Falls back to a generic key icon. */ +export function getProviderIcon(type: string): Component { + switch (type) { + case 'op-service-account': + case 'op-connect': + return OnePassword; + case 'infisical': + return Infisical; + case 'vault': + return HashiCorpVault; + case 'doppler': + return Doppler; + default: + return KeyRound; // any future provider until it has a brand icon + } +} diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 307a29d..49367fe 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -6,6 +6,7 @@ */ import type { SecretProviderConfig, SecretProviderType } from './secretproviders/shared'; +import { SECRET_CONFIG_KEYS } from './secretproviders/shared'; import { db, isPostgres, @@ -86,6 +87,7 @@ import { import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types'; import { encrypt, decrypt, decryptStrict, isEncrypted } from './encryption.js'; import { parseEnvInterpolation } from './env-interpolation'; +import { parseInjectedSecretKeys, serializeInjectedSecretKeys } from './stack-secret-keys'; import { invalidateVulnerabilitiesCache } from './vulnerabilities-cache'; // Re-export for backwards compatibility @@ -375,7 +377,25 @@ export async function updateSecretProvider( if (data.name !== undefined) updateData.name = data.name; if (data.type !== undefined) updateData.type = data.type; if (data.config !== undefined) { - const encrypted = encrypt(JSON.stringify(data.config)); + // The edit form pre-fills non-secret fields but leaves the token blank to mean + // "keep the stored secret". Merge the incoming config OVER the existing one so a + // blank/absent secret keeps its stored value instead of being wiped. Only applies + // when the provider type is unchanged (a type change is a full re-config). + let merged: Record = { ...(data.config as Record) }; + const typeUnchanged = data.type === undefined; + if (typeUnchanged) { + const existing = await getSecretProviderById(id); + if (existing) { + for (const key of SECRET_CONFIG_KEYS) { + const incoming = (data.config as Record)[key]; + if (incoming === undefined || incoming === '') { + const stored = (existing.config as Record)[key]; + if (stored !== undefined) merged[key] = stored; + } + } + } + } + const encrypted = encrypt(JSON.stringify(merged)); if (encrypted) updateData.config = encrypted; } await db.update(secretProviders).set(updateData).where(eq(secretProviders.id, id)); @@ -3075,6 +3095,40 @@ export async function updateStackSource( return true; } +/** + * Persist the names (no values) of secret keys injected from the bound provider on + * the last deploy, so container inspect can mask them without a live provider call. + * Stored as a JSON array on stack_sources; null clears it. No-op if the stack has + * no source row. + */ +export async function setStackInjectedSecretKeys( + stackName: string, + environmentId: number | null | undefined, + keys: string[] +): Promise { + const existing = await getStackSource(stackName, environmentId ?? null); + if (!existing) return; + await db.update(stackSources) + .set({ + injectedSecretKeys: serializeInjectedSecretKeys(keys), + updatedAt: new Date().toISOString() + }) + .where(eq(stackSources.id, existing.id)); +} + +/** + * Read back the provider-injected secret key names for a stack (empty if none). + * Tolerates a malformed/legacy value by returning an empty set. + */ +export async function getStackInjectedSecretKeys( + stackName: string, + environmentId?: number | null +): Promise> { + const source = await getStackSource(stackName, environmentId ?? null); + const raw = (source as { injectedSecretKeys?: string | null } | null)?.injectedSecretKeys; + return parseInjectedSecretKeys(raw); +} + export async function deleteStackSource(stackName: string, environmentId?: number | null): Promise { console.log(`[GitStack] Deleting stack_sources "${stackName}" env=${environmentId}`); // Delete matching record (either with specific envId or NULL) @@ -5241,6 +5295,12 @@ export async function getSecretKeysToMask( const vars = await getStackEnvVars(stackName, environmentId, true); const secretKeyNames = new Set(vars.filter(v => v.isSecret).map(v => v.key)); + // Provider-injected secrets (Vault/Doppler bulk keys, promoted op:// refs) live + // only in the provider, never in stack_env_vars, so add the names persisted on + // the last deploy. Without this they leak plaintext in container inspect. + const injected = await getStackInjectedSecretKeys(stackName, environmentId); + for (const key of injected) secretKeyNames.add(key); + if (secretKeyNames.size === 0) return secretKeyNames; // If we have compose content, parse interpolation references to find diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts index 3ce171a..8194cad 100644 --- a/src/lib/server/db/schema/index.ts +++ b/src/lib/server/db/schema/index.ts @@ -354,6 +354,9 @@ export const stackSources = sqliteTable('stack_sources', { composePath: text('compose_path'), // Custom path to compose file (for stacks with non-default location) envPath: text('env_path'), // Custom path to .env file (for stacks with non-default location) secretProviderId: integer('secret_provider_id').references(() => secretProviders.id, { onDelete: 'set null' }), + // Names (no values) of secret keys injected from the bound provider on the last + // deploy, so container inspect can mask them without a live provider call. + injectedSecretKeys: text('injected_secret_keys'), createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) }, (table) => ({ diff --git a/src/lib/server/db/schema/pg-schema.ts b/src/lib/server/db/schema/pg-schema.ts index db48d4f..f60d15a 100644 --- a/src/lib/server/db/schema/pg-schema.ts +++ b/src/lib/server/db/schema/pg-schema.ts @@ -357,6 +357,9 @@ export const stackSources = pgTable('stack_sources', { composePath: text('compose_path'), // Custom path to compose file (for stacks with non-default location) envPath: text('env_path'), // Custom path to .env file (for stacks with non-default location) secretProviderId: integer('secret_provider_id').references(() => secretProviders.id, { onDelete: 'set null' }), + // Names (no values) of secret keys injected from the bound provider on the last + // deploy, so container inspect can mask them without a live provider call. + injectedSecretKeys: text('injected_secret_keys'), createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() }, (table) => ({ diff --git a/src/lib/server/encryption.ts b/src/lib/server/encryption.ts index 8f39898..d789d02 100644 --- a/src/lib/server/encryption.ts +++ b/src/lib/server/encryption.ts @@ -365,7 +365,8 @@ export async function migrateCredentials(): Promise { ldapConfig, notificationSettings, stackEnvironmentVariables, - backupDestinations + backupDestinations, + secretProviders } = await import('./db/drizzle.js'); let migrated = 0; @@ -461,6 +462,13 @@ export async function migrateCredentials(): Promise { } } + const providers = await db.select().from(secretProviders); + for (const p of providers) { + if (p.config && isEncrypted(p.config)) { + allEncrypted.push({ table: 'secretProviders', id: p.id, field: 'config', value: p.config }); + } + } + // Decrypt all values with old key (still cached at this point) const decryptedValues: Map = new Map(); for (const item of allEncrypted) { @@ -510,6 +518,8 @@ export async function migrateCredentials(): Promise { } else if (item.table === 'backupDestinations') { // Both password and envVars are plain encrypted columns. await tx.update(backupDestinations).set({ [item.field]: reEncrypted }).where(eq(backupDestinations.id, item.id)); + } else if (item.table === 'secretProviders') { + await tx.update(secretProviders).set({ config: reEncrypted }).where(eq(secretProviders.id, item.id)); } migrated++; diff --git a/src/lib/server/secretproviders/connect.ts b/src/lib/server/secretproviders/connect.ts index 53a6ee2..9410b14 100644 --- a/src/lib/server/secretproviders/connect.ts +++ b/src/lib/server/secretproviders/connect.ts @@ -15,6 +15,7 @@ import { request } from 'undici'; import type { ConnectConfig, SecretProvider, TestConnectionResult } from './shared'; +import { assertSafeProviderHost } from './shared'; import { UnsupportedOperationError } from './shared'; /** A vault as returned by GET /v1/vaults. */ @@ -72,6 +73,7 @@ class ReferenceNotFoundError extends Error { /** Normalises a host URL into the Connect v1 API base (no trailing slash). */ function baseUrl(host: string): string { + assertSafeProviderHost(host, '1Password Connect'); return `${host.replace(/\/$/, '')}/v1`; } @@ -132,8 +134,11 @@ async function connectGet( return undefined; } if (statusCode < 200 || statusCode >= 300) { - const detail = await body.text(); - throw new Error(`1Password Connect request failed (${statusCode}): ${detail || 'no body'}`); + // Log the raw body server-side only; the thrown message (which reaches the + // deploy job / client) carries just the status, never arbitrary upstream bytes. + const detail = await body.text().catch(() => ''); + if (detail) console.warn(`[1Password Connect] request ${statusCode}: ${detail}`); + throw new Error(`1Password Connect request failed (${statusCode})`); } return (await body.json()) as T; } @@ -251,9 +256,13 @@ export const connectProvider: SecretProvider = { if (statusCode === 401 || statusCode === 403) { return { ok: false, error: 'Invalid 1Password Connect token' }; } + // Log the raw body server-side; show the client only a message parsed from + // Connect's own {message}/{messages} error shape (never arbitrary bytes). + if (text) console.warn(`[1Password Connect] testConnection ${statusCode}: ${text}`); + const safe = parseProviderError(text); return { ok: false, - error: `1Password Connect request failed (${statusCode}): ${text || 'no body'}` + error: `1Password Connect request failed (${statusCode})${safe ? `: ${safe}` : ''}` }; } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); diff --git a/src/lib/server/secretproviders/doppler.ts b/src/lib/server/secretproviders/doppler.ts new file mode 100644 index 0000000..9d4897b --- /dev/null +++ b/src/lib/server/secretproviders/doppler.ts @@ -0,0 +1,125 @@ +/** + * Doppler provider. + * + * Talks to Doppler's REST API to bulk-pull every secret in a config. Doppler has + * no inline compose-reference syntax, so this provider is bulk-only: + * `resolveSecretReferences` is unsupported and `isReference` is always false. A + * SERVICE token (dp.st.) already identifies the project + config; a PERSONAL token + * (dp.pt.) is account-wide and needs project/config supplied in the config. Either + * way the DOCKHAND_SECRET_SELECTOR is ignored (the config, not the selector, is + * the scope). + * + * Auth is a Doppler service token sent as `Authorization: Bearer `. The + * download endpoint returns a flat `{ KEY: value }` JSON map when asked for + * `format=json`. `host` defaults to Doppler's SaaS API and is overridable only + * for testing against a stub (Doppler is not self-hostable). + * + * Decrypted tokens stay inside this module for the duration of a call. Nothing + * here writes to disk or to the database. + */ + +import { request } from 'undici'; +import type { DopplerConfig, SecretProvider, TestConnectionResult } from './shared'; +import { assertSafeProviderHost, parseProviderError } from './shared'; +import { UnsupportedOperationError } from './shared'; + +const DEFAULT_HOST = 'https://api.doppler.com'; + +/** + * Keys Doppler injects into EVERY config's download - its own metadata about the + * project/config the token is scoped to, not the user's secrets. We drop them so + * they never leak into the stack's environment. (Confirmed against the live API: + * every config returns exactly these three alongside the real secrets.) + */ +export const DOPPLER_META_KEYS = new Set(['DOPPLER_PROJECT', 'DOPPLER_CONFIG', 'DOPPLER_ENVIRONMENT']); + +/** Strips a trailing slash so `${base}${path}` never doubles up. */ +function baseUrl(host: string | undefined): string { + const resolved = (host?.trim() || DEFAULT_HOST).replace(/\/$/, ''); + assertSafeProviderHost(resolved, 'Doppler'); + return resolved; +} + +/** + * The download URL. `include_dynamic_secrets=false` keeps the response a plain + * flat map (dynamic secrets would nest). A service token already implies the + * project/config; a personal token needs them as query params (else the API + * returns 400). We append whichever were provided and let Doppler decide. + */ +function downloadUrl(config: DopplerConfig): string { + let url = `${baseUrl(config.host)}/v3/configs/config/secrets/download?format=json&include_dynamic_secrets=false`; + if (config.project?.trim()) url += `&project=${encodeURIComponent(config.project.trim())}`; + if (config.config?.trim()) url += `&config=${encodeURIComponent(config.config.trim())}`; + return url; +} + +export const dopplerProvider: SecretProvider = { + type: 'doppler', + label: 'Doppler', + supportsReferences: false, + supportsBulk: true, + + isReference(_value: unknown): _value is string { + return false; + }, + + async testConnection(config: DopplerConfig): Promise { + const token = config.token?.trim(); + if (!token) { + return { ok: false, error: 'Token is empty' }; + } + try { + const { statusCode, body } = await request(downloadUrl(config), { + method: 'GET', + headers: { authorization: `Bearer ${token}` } + }); + // Drain the body; on failure log it server-side and show the client only a + // message parsed from Doppler's own {messages:[...]} shape. + const rawBody = await body.text().catch(() => ''); + if (statusCode >= 200 && statusCode < 300) { + return { ok: true }; + } + if (rawBody) console.warn(`[Doppler] testConnection ${statusCode}: ${rawBody}`); + const safe = parseProviderError(rawBody); + return { ok: false, error: `Doppler returned HTTP ${statusCode}${safe ? `: ${safe}` : ''}` }; + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + return { ok: false, error: message || 'Connection failed' }; + } + }, + + async resolveSecretReferences(): Promise> { + throw new UnsupportedOperationError( + 'Doppler does not support inline references; a service token bulk-pulls its whole config.' + ); + }, + + async resolveBulk(config: DopplerConfig, _selector: string): Promise> { + const token = config.token?.trim(); + if (!token) { + throw new Error('[Doppler] Token is required for a bulk pull'); + } + + const { statusCode, body } = await request(downloadUrl(config), { + method: 'GET', + headers: { authorization: `Bearer ${token}` } + }); + + if (statusCode < 200 || statusCode >= 300) { + const detail = await body.text().catch(() => ''); + throw new Error(`[Doppler] Bulk pull failed with HTTP ${statusCode}${detail ? `: ${detail}` : ''}`); + } + + // format=json returns a flat { KEY: value } object; coerce non-string values + // and drop Doppler's own metadata keys (DOPPLER_META_KEYS). + const payload = (await body.json()) as Record; + const result: Record = {}; + for (const [key, value] of Object.entries(payload ?? {})) { + if (DOPPLER_META_KEYS.has(key)) continue; + if (value !== null && value !== undefined) { + result[key] = typeof value === 'string' ? value : String(value); + } + } + return result; + } +}; diff --git a/src/lib/server/secretproviders/index.ts b/src/lib/server/secretproviders/index.ts index 8ac30dc..8fb402b 100644 --- a/src/lib/server/secretproviders/index.ts +++ b/src/lib/server/secretproviders/index.ts @@ -17,6 +17,7 @@ import { serviceAccountProvider } from './service-account'; import { connectProvider } from './connect'; import { infisicalProvider } from './infisical'; import { vaultProvider } from './vault'; +import { dopplerProvider } from './doppler'; // Registered providers. Adding a new backend means dropping a file in this // directory and registering it here; each implements the SecretProvider @@ -25,7 +26,8 @@ const providers: Record = { [serviceAccountProvider.type]: serviceAccountProvider as SecretProvider, [connectProvider.type]: connectProvider as SecretProvider, [infisicalProvider.type]: infisicalProvider as SecretProvider, - [vaultProvider.type]: vaultProvider as SecretProvider + [vaultProvider.type]: vaultProvider as SecretProvider, + [dopplerProvider.type]: dopplerProvider as SecretProvider }; /** Returns the provider for a stored type, or undefined if unknown. */ diff --git a/src/lib/server/secretproviders/infisical.ts b/src/lib/server/secretproviders/infisical.ts index 786efd8..c8a7952 100644 --- a/src/lib/server/secretproviders/infisical.ts +++ b/src/lib/server/secretproviders/infisical.ts @@ -18,6 +18,7 @@ import { request } from 'undici'; import type { InfisicalConfig, SecretProvider, TestConnectionResult } from './shared'; +import { assertSafeProviderHost, parseProviderError } from './shared'; import { UnsupportedOperationError } from './shared'; /** Shape of a single secret in the /api/v3/secrets/raw response. */ @@ -33,6 +34,7 @@ interface RawSecretsResponse { /** Strips a trailing slash so `${base}${path}` never doubles up. */ function baseUrl(host: string): string { + assertSafeProviderHost(host, 'Infisical'); return host.replace(/\/$/, ''); } @@ -91,12 +93,15 @@ export const infisicalProvider: SecretProvider = { headers: { authorization: `Bearer ${token}` } } ); - // Always drain the body so undici can reuse the connection. - await body.text().catch(() => ''); + // Drain the body; on failure log it server-side and show the client only a + // message parsed from Infisical's own {message}/{messages} shape. + const rawBody = await body.text().catch(() => ''); if (statusCode >= 200 && statusCode < 300) { return { ok: true }; } - return { ok: false, error: `Infisical returned HTTP ${statusCode}` }; + if (rawBody) console.warn(`[Infisical] testConnection ${statusCode}: ${rawBody}`); + const safe = parseProviderError(rawBody); + return { ok: false, error: `Infisical returned HTTP ${statusCode}${safe ? `: ${safe}` : ''}` }; } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); return { ok: false, error: message || 'Connection failed' }; @@ -138,11 +143,10 @@ export const infisicalProvider: SecretProvider = { ); if (statusCode < 200 || statusCode >= 300) { - // Drain the body so the connection can be reused, then surface the status. + // Drain the body, log it server-side, but never reflect it to the client. const detail = await body.text().catch(() => ''); - throw new Error( - `[Infisical] Bulk pull failed with HTTP ${statusCode}${detail ? `: ${detail}` : ''}` - ); + if (detail) console.warn(`[Infisical] bulk pull ${statusCode}: ${detail}`); + throw new Error(`[Infisical] Bulk pull failed with HTTP ${statusCode}`); } const payload = (await body.json()) as RawSecretsResponse; diff --git a/src/lib/server/secretproviders/shared.ts b/src/lib/server/secretproviders/shared.ts index b92e8aa..5da35b8 100644 --- a/src/lib/server/secretproviders/shared.ts +++ b/src/lib/server/secretproviders/shared.ts @@ -18,6 +18,8 @@ * persisted here — providers hold it only for the duration of a call. */ +import { isSafeNotificationUrl } from '../url-safety'; + /** * Identifies which backend a stored provider row talks to. Matches the * `secret_providers.type` column and the API. Kept as a widenable string union @@ -28,9 +30,78 @@ export type SecretProviderType = | 'op-connect' | 'infisical' | 'vault' + | 'doppler' // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | (string & {}); +/** + * SSRF guard for a provider's user-supplied host/address. A provider makes + * server-side HTTP requests to whatever host the user configured, so an + * unchecked value lets an authenticated user pivot at Dockhand's network + * (cloud-metadata 169.254.169.254, loopback, internal services) and read the + * response back through testConnection / the bulk pull. Same policy as + * notifications: block loopback + cloud metadata, allow ordinary LAN ranges so a + * self-hosted Vault/Infisical/Connect on the local network still works. Throws on + * an unsafe host; call it before the first request in every REST provider. + */ +export function assertSafeProviderHost(rawUrl: string, label: string): void { + const safe = isSafeNotificationUrl(rawUrl); + if (!safe.ok) { + throw new Error(`${label}: host not allowed (${safe.reason})`); + } +} + +/** + * Sanitises a user-supplied selector/path before it is concatenated into a + * provider's API URL (Vault KV path, Infisical secretPath). The selector comes + * from a stack env var an operator controls, so an unencoded value could inject + * `..` traversal or `?`/`#`/scheme characters and forge a request to a different + * endpoint on the same server. Rejects `..` segments and percent-encodes each + * path segment; a leading slash is stripped by the caller as before. + */ +export function sanitizeSelectorPath(selector: string, label: string): string { + const trimmed = selector.trim().replace(/^\/+/, ''); + const segments = trimmed.split('/'); + for (const seg of segments) { + if (seg === '..' || seg === '.') { + throw new Error(`${label}: selector must not contain path traversal ("${selector}")`); + } + } + return segments.map((s) => encodeURIComponent(s)).join('/'); +} + +/** + * Extracts a short, human error message from a provider's error RESPONSE BODY, but + * only when the body parses as that provider's own documented error JSON shape. + * Used by testConnection (interactive setup) to show an actionable message like + * "permission denied" or "Invalid Auth token" WITHOUT reflecting arbitrary upstream + * bytes: a non-provider host (SSRF probe of a LAN service) returns HTML/other text + * that does not match the shape, so nothing leaks. Returns null when the body is + * absent, unparseable, or not the expected shape - the caller then shows status only. + * + * Shapes: Vault `{errors:[...]}`, Doppler/Infisical `{messages:[...]}` / `{message}`. + */ +export function parseProviderError(rawBody: string | undefined | null): string | null { + if (!rawBody) return null; + let parsed: unknown; + try { + parsed = JSON.parse(rawBody); + } catch { + return null; // not JSON -> not a known provider error shape + } + if (!parsed || typeof parsed !== 'object') return null; + const obj = parsed as Record; + const pick = (v: unknown): string | null => { + if (typeof v === 'string' && v.trim()) return v.trim().slice(0, 200); + if (Array.isArray(v)) { + const strs = v.filter((x) => typeof x === 'string' && x.trim()) as string[]; + if (strs.length) return strs.join('; ').slice(0, 200); + } + return null; + }; + return pick(obj.errors) ?? pick(obj.messages) ?? pick(obj.message) ?? null; +} + /** 1Password service account: a single bearer token. */ export interface ServiceAccountConfig { token: string; @@ -66,12 +137,52 @@ export interface VaultConfig { mount?: string; } +/** + * Doppler: bulk-only. A SERVICE token (dp.st.) is scoped to one config, so the + * token alone identifies it and `project`/`config` are not needed. A PERSONAL + * token (dp.pt.) is account-wide, so it MUST be told which project + config to + * download (the API returns 400 "You must specify a project" otherwise). `host` + * defaults to Doppler's SaaS API; it is overridable only for testing against a + * stub (Doppler has no self-hosted server). + */ +export interface DopplerConfig { + token: string; + host?: string; + /** Required for a personal token (dp.pt.); ignored for a service token. */ + project?: string; + config?: string; +} + /** Persisted (encrypted) config, discriminated by the provider `type`. */ export type SecretProviderConfig = | ServiceAccountConfig | ConnectConfig | InfisicalConfig - | VaultConfig; + | VaultConfig + | DopplerConfig; + +/** + * Config keys that hold a SECRET across every provider type. Only these are stripped + * when a provider config is sent to the client (the edit form); everything else + * (host, address, projectId, environment, path, namespace, mount, project, config) is + * a non-secret coordinate the user needs to see and edit. Keep in sync with the + * `type: 'password'` fields in ProviderModal.svelte's PROVIDER_FIELDS. + */ +export const SECRET_CONFIG_KEYS = new Set(['token']); + +/** + * Returns a copy of a provider config with secret values removed, so the edit form + * can pre-fill the non-secret coordinates without the token ever leaving the server. + * A stripped secret is simply absent (the form treats a blank secret field as + * "keep the stored value"). + */ +export function redactProviderConfig(config: SecretProviderConfig): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(config)) { + if (!SECRET_CONFIG_KEYS.has(key)) out[key] = value; + } + return out; +} export interface TestConnectionResult { ok: boolean; diff --git a/src/lib/server/secretproviders/vault.ts b/src/lib/server/secretproviders/vault.ts index 27c2a86..3a3228d 100644 --- a/src/lib/server/secretproviders/vault.ts +++ b/src/lib/server/secretproviders/vault.ts @@ -18,7 +18,7 @@ */ import { request } from 'undici'; -import { UnsupportedOperationError } from './shared'; +import { UnsupportedOperationError, assertSafeProviderHost, sanitizeSelectorPath, parseProviderError } from './shared'; import type { SecretProvider, TestConnectionResult, VaultConfig } from './shared'; /** Undici response body handle, derived so we don't import undici's types. */ @@ -34,6 +34,7 @@ interface KvV2ReadResponse { /** `${address}/v1`, with any trailing slash on the address removed. */ function apiBase(config: VaultConfig): string { + assertSafeProviderHost(config.address, 'HashiCorp Vault'); return `${config.address.replace(/\/$/, '')}/v1`; } @@ -97,13 +98,18 @@ export const vaultProvider: SecretProvider = { headers: authHeaders(config) }); if (statusCode >= 200 && statusCode < 300) { - await body.dump(); + await body.text().catch(() => ''); return { ok: true }; } - const detail = await readErrorDetail(body); + const rawBody = await body.text().catch(() => ''); + // Log the full upstream body server-side; show the client only a message + // parsed from Vault's own {errors:[...]} shape (a non-Vault host probed via + // SSRF returns other text that won't parse, so nothing leaks). + if (rawBody) console.warn(`[HashiCorp Vault] token lookup ${statusCode}: ${rawBody}`); + const safe = parseProviderError(rawBody); return { ok: false, - error: `Vault returned ${statusCode}${detail ? `: ${detail}` : ''}` + error: `Vault returned ${statusCode}${safe ? `: ${safe}` : ''}` }; } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); @@ -122,8 +128,8 @@ export const vaultProvider: SecretProvider = { }, async resolveBulk(config: VaultConfig, selector: string): Promise> { - const mount = config.mount || 'secret'; - const path = selector.replace(/^\/+/, ''); + const mount = encodeURIComponent(config.mount || 'secret'); + const path = sanitizeSelectorPath(selector, 'HashiCorp Vault'); const { statusCode, body } = await request(`${apiBase(config)}/${mount}/data/${path}`, { method: 'GET', headers: authHeaders(config) @@ -136,9 +142,8 @@ export const vaultProvider: SecretProvider = { } if (statusCode < 200 || statusCode >= 300) { const detail = await readErrorDetail(body); - throw new Error( - `HashiCorp Vault: read failed for ${mount}/${path} (status ${statusCode})${detail ? `: ${detail}` : ''}` - ); + if (detail) console.warn(`[HashiCorp Vault] read ${mount}/${path} ${statusCode}: ${detail}`); + throw new Error(`HashiCorp Vault: read failed for ${mount}/${path} (status ${statusCode})`); } const payload = (await body.json()) as KvV2ReadResponse; diff --git a/src/lib/server/stack-secret-keys.ts b/src/lib/server/stack-secret-keys.ts new file mode 100644 index 0000000..77d233c --- /dev/null +++ b/src/lib/server/stack-secret-keys.ts @@ -0,0 +1,30 @@ +/** + * Pure helpers for the `stack_sources.injected_secret_keys` column - the names (no + * values) of provider-injected secrets recorded on the last deploy so container + * inspect can mask them. Kept dependency-free (no DB import) so it unit-tests without + * spinning up the database. + */ + +/** + * Parses the stored JSON string[] of key names into a Set. Tolerates + * null/empty/malformed/legacy values by returning an empty set (never throws), so a + * bad row degrades to "nothing extra masked" rather than a 500. Non-string array + * members are dropped. + */ +export function parseInjectedSecretKeys(raw: string | null | undefined): Set { + if (!raw) return new Set(); + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? new Set(parsed.filter((k) => typeof k === 'string')) : new Set(); + } catch { + return new Set(); + } +} + +/** + * Serialises the injected key names for storage: a JSON array, or null when empty so + * an unbound / provider-less stack clears the column instead of storing `[]`. + */ +export function serializeInjectedSecretKeys(keys: string[]): string | null { + return keys.length > 0 ? JSON.stringify(keys) : null; +} diff --git a/src/lib/server/url-safety.ts b/src/lib/server/url-safety.ts index de36e0c..2ed4bec 100644 --- a/src/lib/server/url-safety.ts +++ b/src/lib/server/url-safety.ts @@ -58,14 +58,46 @@ export function isSafeNotificationUrl(raw: string): { ok: boolean; reason?: stri */ export type IpCategory = 'loopback' | 'metadata' | 'private' | 'reserved'; +/** + * Extract the embedded IPv4 dotted-quad from an IPv4-mapped/compatible IPv6 host, + * or null. `new URL()` normalizes `::ffff:1.2.3.4` to the HEX form `::ffff:102:304`, + * so a regex on the dotted form alone is dead on the real code path — every mapped + * address must be canonicalized from its hextets. Handles the compressed + * `::ffff:HHHH:HHHH`, the fully-expanded `0:0:0:0:0:ffff:HHHH:HHHH`, the + * `::ffff:0:HHHH:HHHH` (v4-translated), and the deprecated IPv4-compatible + * `::HHHH:HHHH` / `::a.b.c.d`. The last two 16-bit hextets carry the 32-bit v4. + */ +function embeddedV4(h: string): string | null { + // Trailing dotted quad after a v4-mapped/compatible prefix, in either the + // compressed (::ffff:1.2.3.4) or fully-expanded (0:0:0:0:0:ffff:1.2.3.4) form. + const dotted = h.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + if (dotted && /^(::ffff:|::ffff:0:|::|(0:){5}ffff:|(0:){4}0:ffff:)/.test(h)) { + return dotted[1]; + } + // Hex-hextet v4-mapped/compatible forms. Take the last two colon-separated + // groups as the 32-bit v4 IFF the address is one of the embedded-v4 shapes. + const isMapped = + /^::ffff:[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(h) || + /^::ffff:0:[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(h) || + /^(0:){5}ffff:[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(h) || + /^::[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(h); // deprecated v4-compatible ::a:b + if (!isMapped) return null; + const groups = h.split(':').filter((g) => g.length > 0); + if (groups.length < 2) return null; + const hi = parseInt(groups[groups.length - 2], 16); + const lo = parseInt(groups[groups.length - 1], 16); + if (Number.isNaN(hi) || Number.isNaN(lo)) return null; + return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`; +} + export function ipCategory(host: string): IpCategory | null { const h = host.toLowerCase().replace(/^\[|\]$/g, ''); - // IPv6 loopback, then link-local / unique-local (treated as private). + // IPv6 loopback / unspecified, then link-local / unique-local (private). if (h === '::1') return 'loopback'; + if (h === '::' || h === '::0') return 'reserved'; // unspecified / all-interfaces if (h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd')) return 'private'; - // IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254) — judge the embedded v4. - const mapped = h.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); - const v4 = mapped ? mapped[1] : h; + // IPv4-mapped/compatible IPv6 — judge the embedded v4 (see embeddedV4). + const v4 = embeddedV4(h) ?? h; const m = v4.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (m) { const [a, b] = [Number(m[1]), Number(m[2])]; diff --git a/src/routes/api/git/stacks/+server.ts b/src/routes/api/git/stacks/+server.ts index 5b469e4..db2a598 100644 --- a/src/routes/api/git/stacks/+server.ts +++ b/src/routes/api/git/stacks/+server.ts @@ -70,6 +70,17 @@ export const POST: RequestHandler = async (event) => { return json({ error: 'secretProviderId must be a number or null' }, { status: 400 }); } + // Binding a secret provider resolves its secrets into the container at deploy; + // require the secrets permission so a stacks-only user can't exfiltrate a + // provider's secrets by binding it and reading the container env. + if ( + typeof data.secretProviderId === 'number' && + auth.authEnabled && + !(await auth.can('secrets', 'view', data.environmentId || undefined)) + ) { + return json({ error: 'Permission denied: binding a secret provider requires the secrets permission' }, { status: 403 }); + } + // Check for name conflicts with existing stacks (regular/external/git) const existing = await getStackSource(trimmedStackName, data.environmentId || null); if (existing) { diff --git a/src/routes/api/git/stacks/[id]/+server.ts b/src/routes/api/git/stacks/[id]/+server.ts index b55fb05..58a510a 100644 --- a/src/routes/api/git/stacks/[id]/+server.ts +++ b/src/routes/api/git/stacks/[id]/+server.ts @@ -59,6 +59,17 @@ export const PUT: RequestHandler = async (event) => { return json({ error: 'secretProviderId must be a number or null' }, { status: 400 }); } + // Binding a secret provider resolves its secrets into the container at deploy; + // require the secrets permission so a stacks-only user can't exfiltrate a + // provider's secrets by binding it and reading the container env. + if ( + typeof data.secretProviderId === 'number' && + auth.authEnabled && + !(await auth.can('secrets', 'view', existing.environmentId || undefined)) + ) { + return json({ error: 'Permission denied: binding a secret provider requires the secrets permission' }, { status: 403 }); + } + // Validate stack name if it's being changed if (data.stackName !== undefined) { const trimmedStackName = data.stackName.trim(); diff --git a/src/routes/api/secret-providers/[id]/+server.ts b/src/routes/api/secret-providers/[id]/+server.ts index 80dc851..9be4023 100644 --- a/src/routes/api/secret-providers/[id]/+server.ts +++ b/src/routes/api/secret-providers/[id]/+server.ts @@ -7,6 +7,7 @@ import { deleteSecretProvider } from '$lib/server/db'; import { hasProvider } from '$lib/server/secretproviders'; +import { redactProviderConfig } from '$lib/server/secretproviders/shared'; import { authorize } from '$lib/server/authorize'; import { auditSecretProvider } from '$lib/server/audit'; @@ -21,13 +22,15 @@ export const GET: RequestHandler = async ({ params, cookies }) => { return json({ error: 'Invalid secret provider ID' }, { status: 400 }); } - const providers = await getSecretProviders(); - const provider = providers.find((p) => p.id === id); - if (!provider) { + // Load the full (decrypted) row so the edit form can pre-fill the NON-secret + // coordinates (host, projectId, mount, ...). redactProviderConfig strips the + // token before it leaves the server, so the secret never reaches the client. + const full = await getSecretProviderById(id); + if (!full) { return json({ error: 'Secret provider not found' }, { status: 404 }); } - // Summary only, never the decrypted config. - return json(provider); + const { config, ...summary } = full; + return json({ ...summary, config: redactProviderConfig(config) }); }; export const PUT: RequestHandler = async (event) => { diff --git a/src/routes/api/stacks/+server.ts b/src/routes/api/stacks/+server.ts index 22a5034..b34e45b 100644 --- a/src/routes/api/stacks/+server.ts +++ b/src/routes/api/stacks/+server.ts @@ -109,6 +109,17 @@ export const POST: RequestHandler = async (event) => { return json({ error: 'secretProviderId must be a number or null' }, { status: 400 }); } + // Binding a secret provider resolves its secrets into the container at deploy; + // require the secrets permission so a stacks-only user can't exfiltrate a + // provider's secrets by binding it and reading the container env. + if ( + typeof secretProviderId === 'number' && + auth.authEnabled && + !(await auth.can('secrets', 'view', envIdNum)) + ) { + return json({ error: 'Permission denied: binding a secret provider requires the secrets permission' }, { status: 403 }); + } + // If start is false, only create the compose file without deploying if (start === false) { const result = await saveStackComposeFile(name, compose, true, envIdNum, { diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 5f05fbf..5ca2210 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -126,6 +126,10 @@ {#if activeTab === 'git'}{/if} + + {#if activeTab === 'secrets'}{/if} + + {#if activeTab === 'config-sets'}{/if} diff --git a/src/routes/settings/secrets/ProviderModal.svelte b/src/routes/settings/secrets/ProviderModal.svelte index c3bbbab..ee50976 100644 --- a/src/routes/settings/secrets/ProviderModal.svelte +++ b/src/routes/settings/secrets/ProviderModal.svelte @@ -23,6 +23,7 @@ { value: 'op-connect', label: '1Password Connect' }, { value: 'infisical', label: 'Infisical' }, { value: 'vault', label: 'HashiCorp Vault' }, + { value: 'doppler', label: 'Doppler' }, ]; // Config fields per provider type, matching the config shapes in @@ -48,6 +49,11 @@ { key: 'namespace', label: 'Namespace', type: 'text', required: false, placeholder: 'admin (Enterprise / HCP)' }, { key: 'mount', label: 'KV mount', type: 'text', required: false, placeholder: 'secret' }, ], + doppler: [ + { key: 'token', label: 'Token', type: 'password', required: true, placeholder: 'dp.st.... or dp.pt....', hint: 'A service token (dp.st.) already targets one config. A personal token (dp.pt.) also needs the project and config below.' }, + { key: 'project', label: 'Project', type: 'text', required: false, placeholder: 'only for a personal token (dp.pt.)' }, + { key: 'config', label: 'Config', type: 'text', required: false, placeholder: 'e.g. prd' }, + ], }; export function providerTypeLabel(type: string): string { @@ -59,9 +65,12 @@ import { Button } from '$lib/components/ui/button'; import * as Dialog from '$lib/components/ui/dialog'; import * as Select from '$lib/components/ui/select'; - import { Label } from '$lib/components/ui/label'; + import { FieldLabel } from '$lib/components/ui/field-label'; import { Input } from '$lib/components/ui/input'; - import { Plus, Check, RefreshCw, PlugZap } from 'lucide-svelte'; + import { Plus, Check, RefreshCw, PlugZap, KeyRound } from 'lucide-svelte'; + import { scale } from 'svelte/transition'; + import { backOut, cubicIn } from 'svelte/easing'; + import { getProviderIcon } from '$lib/components/provider-icons'; import { toast } from 'svelte-sonner'; import { focusFirstInput } from '$lib/utils'; @@ -88,6 +97,9 @@ let formError = $state(''); let formSaving = $state(false); let formTesting = $state(false); + // Brief green tick on the Test connection button right after a successful test. + let testOk = $state(false); + let testOkTimer: ReturnType | undefined; const fields = $derived(PROVIDER_FIELDS[formType] ?? []); @@ -111,14 +123,34 @@ formType = provider.type; resetConfig(); formError = ''; + // Pre-fill the NON-secret config fields (host, projectId, mount, ...) from + // the server; the token stays blank ('keep existing'). The list only has a + // summary, so fetch the single provider which returns the redacted config. + void loadProviderConfig(provider.id); } else { resetForm(); } } }); - // The config never leaves the server, so on edit the fields start blank. - // Collect only the fields the user actually filled in. + async function loadProviderConfig(id: number) { + try { + const res = await fetch(`/api/secret-providers/${id}`); + if (!res.ok) return; + const data = await res.json(); + const cfg = (data?.config ?? {}) as Record; + const next: Record = {}; + for (const [key, value] of Object.entries(cfg)) { + if (value != null) next[key] = String(value); + } + formConfig = next; // secret fields (token) are absent -> stay blank + } catch { + // leave fields blank on failure - the user can re-enter them + } + } + + // A blank secret field on edit means "keep the stored value"; non-secret fields are + // pre-filled (loadProviderConfig). Collect only the fields the user actually filled. function collectConfig(): Record { const config: Record = {}; for (const field of fields) { @@ -128,8 +160,11 @@ return config; } - function missingRequired(config: Record): string | null { + function missingRequired(config: Record, editing = false): string | null { for (const field of fields) { + // On edit a blank secret (password) field keeps the stored value, so it is + // allowed to be empty; non-secret required fields still must be present. + if (editing && field.type === 'password') continue; if (field.required && !config[field.key]) { return `${field.label} is required`; } @@ -149,16 +184,19 @@ formError = ''; try { const config = collectConfig(); - const configProvided = Object.keys(config).length > 0; + // On edit the token is server-side (blank field = keep stored), so if the + // user hasn't typed a new one, test the STORED config; the pre-filled + // non-secret fields aren't enough to re-test from the client. + const hasNewSecret = fields.some((f) => f.type === 'password' && (config[f.key] ?? '') !== ''); let response: Response; - if (isEditing && !configProvided) { + if (isEditing && !hasNewSecret) { // Test the stored (server-side) config. response = await fetch(`/api/secret-providers/${provider!.id}/test`, { method: 'POST', }); } else { - const missing = missingRequired(config); + const missing = missingRequired(config, isEditing); if (missing) { formError = missing; return; @@ -172,6 +210,9 @@ const data = await response.json(); if (data.ok) { toast.success('Connection works'); + clearTimeout(testOkTimer); + testOk = true; + testOkTimer = setTimeout(() => (testOk = false), 2000); } else { toast.error(data.error || 'Connection failed'); formError = data.error || 'Connection failed'; @@ -190,17 +231,15 @@ } const config = collectConfig(); - const configProvided = Object.keys(config).length > 0; - // On create, a complete config is required. On edit, config is optional - // (blank keeps the stored config); if the user touches any field they - // must supply the whole config, since it is replaced wholesale. - if (!isEditing || configProvided) { - const missing = missingRequired(config); - if (missing) { - formError = missing; - return; - } + // On create, every required field must be present. On EDIT, a blank SECRET + // field (token) means "keep the stored value", so a required secret is allowed + // to be blank; non-secret required fields (host, projectId, ...) are pre-filled + // and still validated. The backend merges the stored secret over the blank. + const missing = missingRequired(config, isEditing); + if (missing) { + formError = missing; + return; } formSaving = true; @@ -210,8 +249,11 @@ const body: Record = { name: formName.trim(), type: formType, + // Always send config; on edit the backend keeps the stored secret when a + // secret field is blank (updateSecretProvider merges), and the non-secret + // fields are pre-filled, so `config` is the full intended coordinates. + config, }; - if (configProvided) body.config = config; const url = isEditing ? `/api/secret-providers/${provider!.id}` @@ -241,6 +283,8 @@ } function handleClose() { + clearTimeout(testOkTimer); + testOk = false; open = false; onClose(); } @@ -255,11 +299,12 @@ } }} > - + - {isEditing ? "Edit" : "Add"} secret provider + + + {isEditing ? "Edit" : "Add"} secret provider +
{#if formError} @@ -268,55 +313,60 @@
{/if}
- +
-
- +
+ - + + {@const TriggerIcon = getProviderIcon(formType)} + {providerTypeLabel(formType)} {#each PROVIDER_TYPES as t (t.value)} + {@const ItemIcon = getProviderIcon(t.value)} - {t.label} + + + {t.label} + {/each}
- {#each fields as field (field.key)} -
- - - {#if field.hint} -

{field.hint}

- {/if} -
- {/each} + +
+ {#each fields as field (field.key)} +
+ + + {#if field.hint} +

{field.hint}

+ {/if} +
+ {/each} +

Configuration is stored encrypted.{#if isEditing} Leave secret fields blank to keep the existing values.{/if} @@ -327,12 +377,19 @@ variant="outline" onclick={testCurrent} disabled={formTesting || formSaving} + class={`transition-colors duration-300 ${testOk ? 'border-green-500/60 text-green-600 dark:text-green-400' : ''}`} > - {#if formTesting} - - {:else} - - {/if} + + {#if formTesting} + + {:else if testOk} + + + + {:else} + + {/if} + Test connection

diff --git a/src/routes/settings/secrets/SecretsTab.svelte b/src/routes/settings/secrets/SecretsTab.svelte index 345037d..e8aa0b4 100644 --- a/src/routes/settings/secrets/SecretsTab.svelte +++ b/src/routes/settings/secrets/SecretsTab.svelte @@ -12,7 +12,10 @@ KeyRound, PlugZap, RefreshCw, + Check, } from 'lucide-svelte'; + import { scale } from 'svelte/transition'; + import { backOut, cubicIn } from 'svelte/easing'; import ConfirmPopover from '$lib/components/ConfirmPopover.svelte'; import { canAccess } from '$lib/stores/auth'; import { EmptyState } from '$lib/components/ui/empty-state'; @@ -20,6 +23,7 @@ type SecretProvider, providerTypeLabel, } from './ProviderModal.svelte'; + import { getProviderIcon } from '$lib/components/provider-icons'; let providers = $state([]); let loading = $state(true); @@ -27,6 +31,9 @@ let editing = $state(null); let confirmDeleteId = $state(null); let testingId = $state(null); + // Brief green tick on the tile's Test button right after a successful test. + let testOkId = $state(null); + let testOkTimer: ReturnType | undefined; async function fetchProviders() { loading = true; @@ -73,6 +80,9 @@ const data = await response.json(); if (data.ok) { toast.success(`${provider.name}: connection works`); + clearTimeout(testOkTimer); + testOkId = provider.id; + testOkTimer = setTimeout(() => (testOkId = null), 2000); } else { toast.error( `${provider.name}: ${data.error || 'connection failed'}`, @@ -121,14 +131,13 @@ {:else}
{#each providers as provider (provider.id)} + {@const ProviderIcon = getProviderIcon(provider.type)}
- + {provider.name} @@ -151,14 +160,19 @@ size="sm" onclick={() => testProvider(provider)} disabled={testingId === provider.id} + class={`transition-colors duration-300 ${testOkId === provider.id ? 'border-green-500/60 text-green-600 dark:text-green-400' : ''}`} > - {#if testingId === provider.id} - - {:else} - - {/if} + + {#if testingId === provider.id} + + {:else if testOkId === provider.id} + + + + {:else} + + {/if} + Test {/if} diff --git a/src/routes/stacks/GitStackModal.svelte b/src/routes/stacks/GitStackModal.svelte index 9adc3bd..bf10231 100644 --- a/src/routes/stacks/GitStackModal.svelte +++ b/src/routes/stacks/GitStackModal.svelte @@ -1188,7 +1188,9 @@
- + + {#if secretProviders.length > 0 || formSecretProviderId !== null}
+ {/if}
- + + {#if secretProviders.length > 0 || formSecretProviderId !== null}
+ {/if}