Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion cmd/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ var catalogCmd = &cobra.Command{
Description string `json:"description"`
AuthType string `json:"auth_type"`
SuggestedCredentialKey string `json:"suggested_credential_key"`
Substitutions []struct {
Key string `json:"key"`
} `json:"substitutions"`
} `json:"services"`
}
if err := json.Unmarshal(respBody, &data); err != nil {
Expand All @@ -63,7 +66,12 @@ var catalogCmd = &cobra.Command{
t := newTable(w)
t.AppendHeader(table.Row{"ID", "NAME", "HOST", "AUTH TYPE", "SUGGESTED KEY"})
for _, svc := range data.Services {
t.AppendRow(table.Row{svc.ID, svc.Name, svc.Host, svc.AuthType, svc.SuggestedCredentialKey})
// "passthrough" alone reads as "no credential involved".
authType := svc.AuthType
if len(svc.Substitutions) > 0 {
authType += " + substitution"
}
t.AppendRow(table.Row{svc.ID, svc.Name, svc.Host, authType, svc.SuggestedCredentialKey})
}
t.Render()
return nil
Expand Down
47 changes: 35 additions & 12 deletions internal/catalog/catalog.go
Original file line number Diff line number Diff line change
@@ -1,41 +1,64 @@
package catalog

import "github.com/Infisical/agent-vault/internal/broker"

// Template represents a preconfigured service template in the catalog.
// Header and Prefix seed api-key auth, Headers seeds custom auth, and
// Substitutions seed the substitution editor independent of AuthType.
type Template struct {
ID string `json:"id"`
Name string `json:"name"`
Host string `json:"host"`
Description string `json:"description"`
AuthType string `json:"auth_type"`
SuggestedCredentialKey string `json:"suggested_credential_key"`
Header string `json:"header,omitempty"`
Prefix string `json:"prefix,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Host string `json:"host"`
Description string `json:"description"`
AuthType string `json:"auth_type"`
SuggestedCredentialKey string `json:"suggested_credential_key"`
Header string `json:"header,omitempty"`
Prefix string `json:"prefix,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Substitutions []broker.Substitution `json:"substitutions,omitempty"`
}

// catalog is the built-in list of common service templates.
var catalog = []Template{
{ID: "anthropic", Name: "Anthropic", Host: "api.anthropic.com", Description: "Claude API", AuthType: "api-key", SuggestedCredentialKey: "ANTHROPIC_API_KEY", Header: "x-api-key"},
{ID: "aws-s3", Name: "AWS S3", Host: "s3.amazonaws.com", Description: "Amazon S3 object storage", AuthType: "custom", SuggestedCredentialKey: "AWS_SECRET_ACCESS_KEY"},
{ID: "cloudflare", Name: "Cloudflare", Host: "api.cloudflare.com", Description: "Cloudflare API", AuthType: "bearer", SuggestedCredentialKey: "CLOUDFLARE_API_TOKEN"},
{ID: "cohere", Name: "Cohere", Host: "api.cohere.com", Description: "Cohere language models", AuthType: "bearer", SuggestedCredentialKey: "CO_API_KEY"},
{ID: "datadog", Name: "Datadog", Host: "api.datadoghq.com", Description: "Monitoring and analytics", AuthType: "api-key", SuggestedCredentialKey: "DATADOG_API_KEY", Header: "DD-API-KEY"},
{ID: "deepseek", Name: "DeepSeek", Host: "api.deepseek.com", Description: "DeepSeek chat and reasoning models", AuthType: "bearer", SuggestedCredentialKey: "DEEPSEEK_API_KEY"},
{ID: "discord", Name: "Discord", Host: "discord.com/api/*", Description: "Discord bot and REST API", AuthType: "api-key", SuggestedCredentialKey: "DISCORD_BOT_TOKEN", Header: "Authorization", Prefix: "Bot "},
{ID: "fireworks", Name: "Fireworks AI", Host: "api.fireworks.ai", Description: "Fast open-model inference", AuthType: "bearer", SuggestedCredentialKey: "FIREWORKS_API_KEY"},
{ID: "gemini", Name: "Google Gemini", Host: "generativelanguage.googleapis.com", Description: "Google Gemini models", AuthType: "api-key", SuggestedCredentialKey: "GEMINI_API_KEY", Header: "x-goog-api-key"},
{ID: "github", Name: "GitHub", Host: "api.github.com", Description: "GitHub REST API", AuthType: "bearer", SuggestedCredentialKey: "GITHUB_TOKEN"},
{ID: "gitlab", Name: "GitLab", Host: "gitlab.com/api/*", Description: "GitLab repos and pipelines", AuthType: "api-key", SuggestedCredentialKey: "GITLAB_TOKEN", Header: "PRIVATE-TOKEN"},
{ID: "groq", Name: "Groq", Host: "api.groq.com", Description: "Fast model inference from Groq", AuthType: "bearer", SuggestedCredentialKey: "GROQ_API_KEY"},
{ID: "jira", Name: "Jira", Host: "*.atlassian.net", Description: "Atlassian Jira project tracking", AuthType: "basic", SuggestedCredentialKey: "JIRA_API_TOKEN"},
{ID: "linear", Name: "Linear", Host: "api.linear.app", Description: "Project management and issue tracking", AuthType: "bearer", SuggestedCredentialKey: "LINEAR_API_KEY"},
{ID: "linear", Name: "Linear", Host: "api.linear.app", Description: "Project management and issue tracking", AuthType: "api-key", SuggestedCredentialKey: "LINEAR_API_KEY", Header: "Authorization"},
{ID: "mistral", Name: "Mistral AI", Host: "api.mistral.ai", Description: "Mistral chat and embedding models", AuthType: "bearer", SuggestedCredentialKey: "MISTRAL_API_KEY"},
{ID: "notion", Name: "Notion", Host: "api.notion.com", Description: "Notion workspace API", AuthType: "bearer", SuggestedCredentialKey: "NOTION_TOKEN"},
{ID: "npm", Name: "NPM", Host: "registry.npmjs.org", Description: "NPM Default registry", AuthType: "bearer", SuggestedCredentialKey: "NPM_TOKEN"},
{ID: "npmgh", Name: "Github NPM registry", Host: "npm.pkg.github.com", Description: "Github's NPM registry", AuthType: "bearer", SuggestedCredentialKey: "NPM_GH_TOKEN"},
{ID: "openai", Name: "OpenAI", Host: "api.openai.com", Description: "OpenAI / ChatGPT API", AuthType: "bearer", SuggestedCredentialKey: "OPENAI_API_KEY"},
{ID: "pagerduty", Name: "PagerDuty", Host: "api.pagerduty.com", Description: "Incident management", AuthType: "bearer", SuggestedCredentialKey: "PAGERDUTY_TOKEN"},
{ID: "openrouter", Name: "OpenRouter", Host: "openrouter.ai", Description: "One key for many AI models", AuthType: "bearer", SuggestedCredentialKey: "OPENROUTER_API_KEY"},
{ID: "pagerduty", Name: "PagerDuty", Host: "api.pagerduty.com", Description: "Incident management", AuthType: "custom", SuggestedCredentialKey: "PAGERDUTY_TOKEN", Headers: map[string]string{
"Authorization": "Token token={{ PAGERDUTY_TOKEN }}",
}},
{ID: "perplexity", Name: "Perplexity", Host: "api.perplexity.ai", Description: "Perplexity answer engine", AuthType: "bearer", SuggestedCredentialKey: "PERPLEXITY_API_KEY"},
{ID: "postmark", Name: "Postmark", Host: "api.postmarkapp.com", Description: "Transactional email service", AuthType: "api-key", SuggestedCredentialKey: "POSTMARK_SERVER_TOKEN", Header: "X-Postmark-Server-Token"},
{ID: "resend", Name: "Resend", Host: "api.resend.com", Description: "Email API for developers", AuthType: "bearer", SuggestedCredentialKey: "RESEND_API_KEY"},
{ID: "sendgrid", Name: "SendGrid", Host: "api.sendgrid.com", Description: "Email delivery API", AuthType: "bearer", SuggestedCredentialKey: "SENDGRID_API_KEY"},
{ID: "sentry", Name: "Sentry", Host: "sentry.io", Description: "Error tracking and performance monitoring", AuthType: "bearer", SuggestedCredentialKey: "SENTRY_AUTH_TOKEN"},
{ID: "shopify", Name: "Shopify", Host: "*.myshopify.com", Description: "Shopify e-commerce API", AuthType: "api-key", SuggestedCredentialKey: "SHOPIFY_ACCESS_TOKEN", Header: "X-Shopify-Access-Token"},
{ID: "slack", Name: "Slack", Host: "slack.com", Description: "Slack Web API", AuthType: "bearer", SuggestedCredentialKey: "SLACK_TOKEN"},
{ID: "stripe", Name: "Stripe", Host: "api.stripe.com", Description: "Payment processing API", AuthType: "bearer", SuggestedCredentialKey: "STRIPE_KEY"},
{ID: "supabase", Name: "Supabase", Host: "*.supabase.co", Description: "Supabase backend-as-a-service", AuthType: "bearer", SuggestedCredentialKey: "SUPABASE_KEY"},
{ID: "stripe", Name: "Stripe", Host: "api.stripe.com", Description: "Payment processing API", AuthType: "bearer", SuggestedCredentialKey: "STRIPE_SECRET_KEY"},
{ID: "supabase", Name: "Supabase", Host: "*.supabase.co", Description: "Supabase backend-as-a-service", AuthType: "api-key", SuggestedCredentialKey: "SUPABASE_KEY", Header: "apikey"},
{ID: "telegram", Name: "Telegram", Host: "api.telegram.org", Description: "Telegram bot API", AuthType: "passthrough", SuggestedCredentialKey: "TELEGRAM_BOT_TOKEN", Substitutions: []broker.Substitution{
{Key: "TELEGRAM_BOT_TOKEN", Placeholder: "__TELEGRAM_BOT_TOKEN__", In: []string{"path"}},
}},
{ID: "together", Name: "Together AI", Host: "api.together.ai", Description: "Open models on Together AI", AuthType: "bearer", SuggestedCredentialKey: "TOGETHER_API_KEY"},
{ID: "twilio", Name: "Twilio", Host: "api.twilio.com", Description: "Communication APIs (SMS, voice, email)", AuthType: "basic", SuggestedCredentialKey: "TWILIO_AUTH_TOKEN"},
{ID: "vercel", Name: "Vercel", Host: "api.vercel.com", Description: "Vercel deployment platform", AuthType: "bearer", SuggestedCredentialKey: "VERCEL_TOKEN"},
{ID: "xai", Name: "xAI (Grok)", Host: "api.x.ai", Description: "Grok models from xAI", AuthType: "bearer", SuggestedCredentialKey: "XAI_API_KEY"},
}

// GetAll returns all available service templates.
Expand Down
98 changes: 98 additions & 0 deletions internal/catalog/catalog_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package catalog

import (
"net/http"
"net/url"
"testing"

"github.com/Infisical/agent-vault/internal/broker"
"github.com/Infisical/agent-vault/internal/brokercore"
)

// serviceFromTemplate mirrors what the add-service form's applyPreset builds
// from a template, so validating it here catches a preset that would produce
// an unsubmittable form or a rejected proposal.
func serviceFromTemplate(t Template) broker.Service {
auth := broker.Auth{Type: t.AuthType}
switch t.AuthType {
case "bearer":
auth.Token = t.SuggestedCredentialKey
case "basic":
// The form seeds the token into the password slot; the username
// (AccountSID, Jira email) is user-specific, so stand one in here.
auth.Username = "USERNAME"
auth.Password = t.SuggestedCredentialKey
case "api-key":
auth.Key = t.SuggestedCredentialKey
auth.Header = t.Header
auth.Prefix = t.Prefix
case "custom":
auth.Headers = t.Headers
}

host, path, port := broker.SplitInlineHost(t.Host, "")
return broker.Service{
Name: t.ID,
Host: host,
Path: path,
Port: port,
Auth: auth,
Substitutions: t.Substitutions,
}
}

func TestCatalogTemplatesAreValidServices(t *testing.T) {
seen := make(map[string]bool)
for _, tpl := range GetAll() {
t.Run(tpl.ID, func(t *testing.T) {
// GetByID returns the first match, so a duplicate would silently
// shadow the later template.
if seen[tpl.ID] {
t.Fatalf("duplicate template id %q", tpl.ID)
}
seen[tpl.ID] = true

if !broker.CredentialKeyPattern.MatchString(tpl.SuggestedCredentialKey) {
t.Errorf("suggested_credential_key %q must be UPPER_SNAKE_CASE", tpl.SuggestedCredentialKey)
}
// aws-s3 needs SigV4 request signing, which no auth type can
// express, so it ships without headers and is skipped here.
if tpl.ID == "aws-s3" {
return
}
cfg := broker.Config{Vault: "default", Services: []broker.Service{serviceFromTemplate(tpl)}}
if err := broker.Validate(&cfg); err != nil {
t.Errorf("template does not produce a valid service: %v", err)
}
})
}
}

// The Telegram bot token travels as a path segment, and its real
// `<id>:<token>` shape must survive path escaping intact.
func TestTelegramTemplateRewritesPath(t *testing.T) {
tpl := GetByID("telegram")
if tpl == nil {
t.Fatal("telegram template missing")
}
subs := make([]brokercore.ResolvedSubstitution, 0, len(tpl.Substitutions))
for _, sub := range tpl.Substitutions {
subs = append(subs, brokercore.ResolvedSubstitution{
Placeholder: sub.Placeholder,
Value: "123456789:AAH-abc_DEF",
In: sub.NormalizedIn(),
})
}

u, err := url.Parse("https://api.telegram.org/bot__TELEGRAM_BOT_TOKEN__/sendMessage")
if err != nil {
t.Fatal(err)
}
if err := brokercore.ApplySubstitutions(u, http.Header{}, subs); err != nil {
t.Fatalf("ApplySubstitutions: %v", err)
}
const want = "https://api.telegram.org/bot123456789:AAH-abc_DEF/sendMessage"
if got := u.String(); got != want {
t.Errorf("got %q, want %q", got, want)
}
}
17 changes: 17 additions & 0 deletions web/src/pages/vault/ServicesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ interface CatalogTemplate {
suggested_credential_key: string;
header?: string;
prefix?: string;
headers?: Record<string, string>;
substitutions?: Substitution[];
}

function isEnabled(service: Service): boolean {
Expand Down Expand Up @@ -532,6 +534,7 @@ function ServiceModal({
setApiKeyPrefix("");
setCustomHeaders([{ _id: nextRowId(), name: "", value: "" }]);
setSubs([]);
setSubsExpanded(false);
}

function applyPreset(id: string) {
Expand All @@ -551,6 +554,20 @@ function ServiceModal({
setApiKeyHeader(tpl.header ?? "");
setApiKeyPrefix(tpl.prefix ?? "");
}
if (tpl.auth_type === "custom" && tpl.headers) {
setCustomHeaders(Object.entries(tpl.headers).map(([name, value]) => ({ _id: nextRowId(), name, value })));
}
if (tpl.substitutions && tpl.substitutions.length > 0) {
setSubs(
tpl.substitutions.map((s) => ({
_id: nextRowId(),
key: s.key,
placeholder: s.placeholder,
in: s.in && s.in.length > 0 ? [...s.in] : [...DEFAULT_SUBSTITUTION_SURFACES],
}))
);
setSubsExpanded(true);
}
}

useEffect(() => {
Expand Down