stack secrets from external providers (1Password, HashiCorp Vault, Infisical and Doppler)

This commit is contained in:
jarek committed 2026-08-13 10:20:29 +02:00
1 parent d7f435dd5f
commit f43736d94a
28 files changed
+641 -74

No files matched your search

+1
View File
@@ -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;
+2 -1
View File
@@ -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;
@@ -0,0 +1,10 @@
<script lang="ts">
let { class: className = 'w-4 h-4' }: { class?: string } = $props();
</script>
<svg viewBox="1 0 99.6 99.7" xmlns="http://www.w3.org/2000/svg" class={className} fill="currentColor">
<path d="M64.3.3A7.5 7.5 0 0 0 55 6l-4 19.3a32 32 0 0 1-25 25l-19 4a7.5 7.5 0 1 0 4 14.5l18.3-6c12.3-4.1 25.8-.5 34.3 9.1l13 14.5a7.5 7.5 0 1 0 10.6-10.6l-14.6-13a32 32 0 0 1-9.2-34.4l6.1-18.5c1.4-4.1-1-8.5-5.2-9.6"/>
<path d="M14.2 24.4A8 8 0 1 1 25.6 13l9.8 10.8A7.4 7.4 0 0 1 25 34z"/>
<path d="M90 30.4A8 8 0 1 1 94.2 46L80 49a7.4 7.4 0 0 1-3.8-14.2z"/>
<path d="M46.6 93.3A8 8 0 1 1 31 89.2l4.4-13.9a7.4 7.4 0 0 1 14.2 3.8z"/>
</svg>
@@ -0,0 +1,7 @@
<script lang="ts">
let { class: className = 'w-4 h-4' }: { class?: string } = $props();
</script>
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" class={className} fill="currentColor">
<path d="M0 0l7.971 15.516L16 0H0zm6.732 6.16h-1.27V4.89h1.27v1.27zm0-1.906h-1.27V2.985h1.27v1.269zm1.904 3.81h-1.27v-1.27h1.27v1.27zm0-1.905h-1.27V4.89h1.27v1.27zm0-1.905h-1.27V2.985h1.27v1.269zm1.894 1.905H9.26V4.89h1.27v1.27zM9.26 4.254V2.985h1.27v1.269H9.26z"/>
</svg>
@@ -0,0 +1,7 @@
<script lang="ts">
let { class: className = 'w-4 h-4' }: { class?: string } = $props();
</script>
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" class={className} fill="currentColor">
<path d="M477.8 173.6c-22.1-22.2-51.5-34.5-82.7-34.5-29.5 0-59.5 12.8-89.4 38.1-21.2 17.9-38 38.6-49.7 55.2-10.2-13.7-26.2-33.2-46.6-51.3-14.9-13.4-29.8-23.9-44.2-31.2-16.7-8.5-32.9-12.8-48-12.8-31.3 0-60.8 12.2-82.9 34.5S0 223.5 0 255s12.2 61.2 34.3 83.5c22.2 22.2 51.6 34.5 82.9 34.5 35.4 0 70.7-25.2 94.1-46.3 20.5-18.6 35.9-37.5 45-49.7 16.2 22.3 35 46.1 55.4 63.9 26.7 23.3 53.2 34.1 83.4 34.1 31.2 0 60.5-12.3 82.7-34.6 22.1-22.2 34.3-51.9 34.3-83.4s-12.2-61.1-34.3-83.4M180.8 293c-24.4 22-47.5 34.6-63.6 34.6-39.6 0-71.8-32.6-71.8-72.6s32.2-72.6 71.8-72.6c16.5 0 38.5 11.6 62 32.5 12.6 11.2 25.5 25.3 36.9 40-10.5 13.4-23 27-35.3 38.1M395 329.5c-18.8 0-35.4-7-53.6-22.9C328 295 314.1 279 296 254.5c12.1-16.6 25.4-31.2 39-42.9 21.1-17.8 41.9-27.3 60-27.3 39.5 0 71.6 32.6 71.6 72.6s-32.1 72.6-71.6 72.6"/>
</svg>
@@ -0,0 +1,7 @@
<script lang="ts">
let { class: className = 'w-4 h-4' }: { class?: string } = $props();
</script>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class={className} fill="currentColor" role="img">
<path d="M10.51,10.013V7.299c0-0.306,0.248-0.554,0.554-0.554h1.911c0.306,0,0.554,0.248,0.554,0.554v4.968 c0,0.076-0.038,0.134-0.076,0.191l-0.478,0.478c-0.115,0.115-0.115,0.287,0,0.382l0.478,0.478c0.057,0.057,0.076,0.115,0.076,0.191 v2.713c0,0.306-0.248,0.554-0.554,0.554h-1.911c-0.306,0-0.554-0.248-0.554-0.554v-4.968c0-0.076,0.038-0.134,0.076-0.191 l0.478-0.478c0.115-0.115,0.115-0.287,0-0.382l-0.478-0.478C10.529,10.146,10.51,10.089,10.51,10.013z M19.127,12 c0-3.936-3.191-7.127-7.127-7.127S4.873,8.064,4.873,12S8.064,19.127,12,19.127S19.127,15.936,19.127,12z M21.382,12 c0,5.178-4.204,9.363-9.363,9.363c-5.178,0-9.363-4.204-9.363-9.363c0-5.178,4.204-9.363,9.363-9.363 C17.178,2.637,21.382,6.822,21.382,12z M0.764,12c0,6.21,5.025,11.236,11.236,11.236S23.236,18.21,23.236,12S18.21,0.764,12,0.764 S0.764,5.79,0.764,12z M0,12C0,5.369,5.369,0,12,0c6.631,0,12,5.369,12,12s-5.369,12-12,12S0,18.631,0,12z"/>
</svg>
@@ -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
}
}
+61 -1
View File
@@ -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<string, unknown> = { ...(data.config as Record<string, unknown>) };
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<string, unknown>)[key];
if (incoming === undefined || incoming === '') {
const stored = (existing.config as Record<string, unknown>)[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<void> {
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<Set<string>> {
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<boolean> {
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
+3
View File
@@ -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) => ({
+3
View File
@@ -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) => ({
+11 -1
View File
@@ -365,7 +365,8 @@ export async function migrateCredentials(): Promise<void> {
ldapConfig,
notificationSettings,
stackEnvironmentVariables,
backupDestinations
backupDestinations,
secretProviders
} = await import('./db/drizzle.js');
let migrated = 0;
@@ -461,6 +462,13 @@ export async function migrateCredentials(): Promise<void> {
}
}
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<string, string> = new Map();
for (const item of allEncrypted) {
@@ -510,6 +518,8 @@ export async function migrateCredentials(): Promise<void> {
} 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++;
+12 -3
View File
@@ -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<T>(
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<ConnectConfig> = {
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);
+125
View File
@@ -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 <token>`. 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<DopplerConfig> = {
type: 'doppler',
label: 'Doppler',
supportsReferences: false,
supportsBulk: true,
isReference(_value: unknown): _value is string {
return false;
},
async testConnection(config: DopplerConfig): Promise<TestConnectionResult> {
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<Map<string, string>> {
throw new UnsupportedOperationError(
'Doppler does not support inline references; a service token bulk-pulls its whole config.'
);
},
async resolveBulk(config: DopplerConfig, _selector: string): Promise<Record<string, string>> {
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<string, unknown>;
const result: Record<string, string> = {};
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;
}
};
+3 -1
View File
@@ -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<string, SecretProvider> = {
[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. */
+11 -7
View File
@@ -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<InfisicalConfig> = {
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<InfisicalConfig> = {
);
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;
+112 -1
View File
@@ -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<string, unknown>;
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<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(config)) {
if (!SECRET_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
export interface TestConnectionResult {
ok: boolean;
+14 -9
View File
@@ -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<VaultConfig> = {
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<VaultConfig> = {
},
async resolveBulk(config: VaultConfig, selector: string): Promise<Record<string, string>> {
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<VaultConfig> = {
}
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;
+30
View File
@@ -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<string> {
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;
}
+36 -4
View File
@@ -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])];
+11
View File
@@ -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) {
+11
View File
@@ -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();
@@ -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) => {
+11
View File
@@ -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, {
+4
View File
@@ -126,6 +126,10 @@
{#if activeTab === 'git'}<GitTab />{/if}
</Tabs.Content>
<Tabs.Content value="secrets" class="flex-1 min-h-0 overflow-y-auto pr-5">
{#if activeTab === 'secrets'}<SecretsTab />{/if}
</Tabs.Content>
<Tabs.Content value="config-sets" class="flex-1 min-h-0 overflow-y-auto pr-5">
{#if activeTab === 'config-sets'}<ConfigSetsTab />{/if}
</Tabs.Content>
@@ -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<typeof setTimeout> | 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<string, unknown>;
const next: Record<string, string> = {};
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<string, string> {
const config: Record<string, string> = {};
for (const field of fields) {
@@ -128,8 +160,11 @@
return config;
}
function missingRequired(config: Record<string, string>): string | null {
function missingRequired(config: Record<string, string>, 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,18 +231,16 @@
}
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);
// 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;
formError = '';
@@ -210,8 +249,11 @@
const body: Record<string, unknown> = {
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 @@
}
}}
>
<Dialog.Content class="max-w-md">
<Dialog.Content class="sm:max-w-lg">
<Dialog.Header>
<Dialog.Title
>{isEditing ? "Edit" : "Add"} secret provider</Dialog.Title
>
<Dialog.Title class="flex items-center gap-2">
<KeyRound class="w-5 h-5 text-muted-foreground" />
{isEditing ? "Edit" : "Add"} secret provider
</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
{#if formError}
@@ -268,42 +313,46 @@
</div>
{/if}
<div class="space-y-2">
<Label for="provider-name">Name</Label>
<FieldLabel label="Name" forId="provider-name" required showOptional={false} />
<Input
id="provider-name"
bind:value={formName}
placeholder="Production secrets"
/>
</div>
<div class="space-y-2">
<Label for="provider-type">Provider</Label>
<div class="space-y-2 sm:w-[calc(50%-0.5rem)]">
<FieldLabel label="Provider" forId="provider-type" required showOptional={false} />
<Select.Root
type="single"
value={formType}
onValueChange={onTypeChange}
disabled={isEditing}
>
<Select.Trigger id="provider-type">
<Select.Trigger id="provider-type" class="flex w-full items-center gap-2">
{@const TriggerIcon = getProviderIcon(formType)}
<TriggerIcon class="w-4 h-4 shrink-0 text-muted-foreground" />
{providerTypeLabel(formType)}
</Select.Trigger>
<Select.Content>
{#each PROVIDER_TYPES as t (t.value)}
{@const ItemIcon = getProviderIcon(t.value)}
<Select.Item value={t.value} label={t.label}>
<span class="flex items-center gap-2">
<ItemIcon class="w-4 h-4 shrink-0 text-muted-foreground" />
{t.label}
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Provider config fields in a 2-column grid. min-height fits the tallest
provider (5 fields = 3 rows) so the modal keeps a fixed height and does
not jump when switching providers. -->
<div class="grid grid-cols-2 gap-x-4 gap-y-3" style="min-height: 13.5rem;">
{#each fields as field (field.key)}
<div class="space-y-2">
<Label for={`provider-${field.key}`}>
{field.label}{#if !field.required}<span
class="text-muted-foreground"
>
(optional)</span
>{/if}
</Label>
<FieldLabel label={field.label} forId={`provider-${field.key}`} required={field.required} />
<Input
id={`provider-${field.key}`}
type={field.type}
@@ -317,6 +366,7 @@
{/if}
</div>
{/each}
</div>
<p class="text-xs text-muted-foreground">
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' : ''}`}
>
<span class="inline-flex w-4 h-4 mr-1 items-center justify-center shrink-0">
{#if formTesting}
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
<RefreshCw class="w-4 h-4 animate-spin" />
{:else if testOk}
<span in:scale={{ duration: 260, start: 0.4, easing: backOut }} out:scale={{ duration: 150, start: 0.6, easing: cubicIn }}>
<Check class="w-4 h-4 text-green-600 dark:text-green-400" />
</span>
{:else}
<PlugZap class="w-4 h-4 mr-1" />
<PlugZap class="w-4 h-4" />
{/if}
</span>
Test connection
</Button>
<div class="flex-1"></div>
+20 -6
View File
@@ -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<SecretProvider[]>([]);
let loading = $state(true);
@@ -27,6 +31,9 @@
let editing = $state<SecretProvider | null>(null);
let confirmDeleteId = $state<number | null>(null);
let testingId = $state<number | null>(null);
// Brief green tick on the tile's Test button right after a successful test.
let testOkId = $state<number | null>(null);
let testOkTimer: ReturnType<typeof setTimeout> | 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}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each providers as provider (provider.id)}
{@const ProviderIcon = getProviderIcon(provider.type)}
<div out:fade={{ duration: 200 }}>
<Card.Root>
<Card.Header class="pb-2">
<div class="flex items-start justify-between">
<div class="flex items-center gap-2">
<KeyRound
class="w-5 h-5 text-muted-foreground"
/>
<ProviderIcon class="w-5 h-5 text-muted-foreground" />
<Card.Title class="text-base"
>{provider.name}</Card.Title
>
@@ -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' : ''}`}
>
<span class="inline-flex w-3 h-3 mr-1 items-center justify-center shrink-0">
{#if testingId === provider.id}
<RefreshCw
class="w-3 h-3 animate-spin"
/>
<RefreshCw class="w-3 h-3 animate-spin" />
{:else if testOkId === provider.id}
<span in:scale={{ duration: 260, start: 0.4, easing: backOut }} out:scale={{ duration: 150, start: 0.6, easing: cubicIn }}>
<Check class="w-3 h-3 text-green-600 dark:text-green-400" />
</span>
{:else}
<PlugZap class="w-3 h-3" />
{/if}
</span>
Test
</Button>
{/if}
+4 -1
View File
@@ -1188,7 +1188,9 @@
<!-- Right column: Environment Variables -->
<div class="flex-1 min-w-0 flex flex-col overflow-hidden bg-zinc-50 dark:bg-zinc-800/50">
<!-- Secret provider selector -->
<!-- Secret provider selector - hidden when no external provider is configured
(still shown if this stack already has one bound, so it can be cleared). -->
{#if secretProviders.length > 0 || formSecretProviderId !== null}
<div class="px-3 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-100/60 dark:bg-zinc-800/60 flex items-center gap-2 text-xs">
<Label for="secret-provider-select-git" class="text-xs text-muted-foreground shrink-0">Secret provider</Label>
<Select.Root
@@ -1223,6 +1225,7 @@
</Tooltip.Content>
</Tooltip.Root>
</div>
{/if}
<StackEnvVarsPanel
bind:variables={envVars}
placeholder={{ key: 'MY_VAR', value: 'value' }}
+4 -1
View File
@@ -1758,7 +1758,9 @@
</div>
<!-- Environment variables panel -->
<div class="flex-1 min-w-0 flex flex-col overflow-hidden bg-zinc-50 dark:bg-zinc-800/50">
<!-- Secret provider selector -->
<!-- Secret provider selector - hidden when no external provider is configured
(still shown if this stack already has one bound, so it can be cleared). -->
{#if secretProviders.length > 0 || formSecretProviderId !== null}
<div class="px-3 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-100/60 dark:bg-zinc-800/60 flex items-center gap-2 text-xs">
<Label for="secret-provider-select" class="text-xs text-muted-foreground shrink-0">Secret provider</Label>
<Select.Root
@@ -1798,6 +1800,7 @@
</Tooltip.Root>
</Tooltip.Provider>
</div>
{/if}
<StackEnvVarsPanel
bind:this={envVarsPanelRef}
bind:variables={envVars}