From 3b5669ada4f832e28ec083e500e6ad17f051de97 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Fri, 26 Jun 2026 16:37:08 +0300 Subject: [PATCH 1/2] vault mTLS: add mTLS client cert support to Vault clients - Added client_cert/client_key fields to VaultSettings - Updated both vault clients to pass Identity to reqwest - docker-compose.dev.yml: added VAULT_CLIENT_CERT/KEY env vars --- docker-compose.dev.yml | 3 +++ src/configuration.rs | 16 ++++++++++++++ src/helpers/vault.rs | 17 ++++++++++++++- src/services/vault_service.rs | 41 +++++++++++++++++++++++++++++++---- 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 7eef27e2..703bdb8f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -42,6 +42,9 @@ services: # Vault — must point to the real Vault service in the TryDirect network - VAULT_ADDRESS=https://vault.try.direct - VAULT_TOKEN=${STACKER_VAULT_TOKEN:-change-me} + # mTLS client cert for Vault — inline PEMs + - VAULT_CLIENT_CERT=${VAULT_CLIENT_CERT:-} + - VAULT_CLIENT_KEY=${VAULT_CLIENT_KEY:-} depends_on: stackerdb: condition: service_healthy diff --git a/src/configuration.rs b/src/configuration.rs index 884e4c8f..1544ba93 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -474,6 +474,14 @@ pub struct VaultSettings { pub api_prefix: String, #[serde(default)] pub ssh_key_path_prefix: Option, + /// Client certificate PEM for mTLS (inline) or file path. + /// Read from VAULT_CLIENT_CERT env var. + #[serde(default)] + pub client_cert: Option, + /// Client key PEM for mTLS (inline) or file path. + /// Read from VAULT_CLIENT_KEY env var. + #[serde(default)] + pub client_key: Option, } impl std::fmt::Debug for VaultSettings { @@ -484,6 +492,8 @@ impl std::fmt::Debug for VaultSettings { .field("agent_path_prefix", &self.agent_path_prefix) .field("api_prefix", &self.api_prefix) .field("ssh_key_path_prefix", &self.ssh_key_path_prefix) + .field("client_cert", &self.client_cert.as_ref().map(|_| "[REDACTED]")) + .field("client_key", &self.client_key.as_ref().map(|_| "[REDACTED]")) .finish() } } @@ -496,6 +506,8 @@ impl Default for VaultSettings { agent_path_prefix: "agent".to_string(), api_prefix: Self::default_api_prefix(), ssh_key_path_prefix: Some("users".to_string()), + client_cert: None, + client_key: None, } } } @@ -517,6 +529,8 @@ impl VaultSettings { self.ssh_key_path_prefix .unwrap_or_else(|| "users".to_string()), ); + let client_cert = std::env::var("VAULT_CLIENT_CERT").ok().or(self.client_cert); + let client_key = std::env::var("VAULT_CLIENT_KEY").ok().or(self.client_key); VaultSettings { address, @@ -524,6 +538,8 @@ impl VaultSettings { agent_path_prefix, api_prefix, ssh_key_path_prefix: Some(ssh_key_path_prefix), + client_cert, + client_key, } } } diff --git a/src/helpers/vault.rs b/src/helpers/vault.rs index 575758a6..57ddadc7 100644 --- a/src/helpers/vault.rs +++ b/src/helpers/vault.rs @@ -1,5 +1,6 @@ use crate::configuration::VaultSettings; use reqwest::Client; +use reqwest::Identity; use serde_json::json; pub struct VaultClient { @@ -25,8 +26,22 @@ impl std::fmt::Debug for VaultClient { impl VaultClient { pub fn new(settings: &VaultSettings) -> Self { + let mut client_builder = Client::builder(); + if let (Some(cert), Some(key)) = (&settings.client_cert, &settings.client_key) { + let identity_pem = format!("{}\n{}", cert, key); + match Identity::from_pem(identity_pem.as_bytes()) { + Ok(identity) => { + client_builder = client_builder.identity(identity); + } + Err(e) => { + tracing::warn!("Failed to load mTLS identity for Vault client: {}", e); + } + } + } + let client = client_builder.build().unwrap_or_else(|_| Client::new()); + Self { - client: Client::new(), + client, address: settings.address.clone(), token: settings.token.clone(), agent_path_prefix: settings.agent_path_prefix.clone(), diff --git a/src/services/vault_service.rs b/src/services/vault_service.rs index be818d49..67209f95 100644 --- a/src/services/vault_service.rs +++ b/src/services/vault_service.rs @@ -8,6 +8,7 @@ use anyhow::Result; use reqwest::Client; +use reqwest::Identity; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::time::Duration; @@ -95,8 +96,22 @@ impl VaultService { pub fn from_settings( settings: &crate::configuration::VaultSettings, ) -> Result { - let http_client = Client::builder() - .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)) + let mut builder = Client::builder() + .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)); + + if let (Some(cert), Some(key)) = (&settings.client_cert, &settings.client_key) { + let identity_pem = format!("{}\n{}", cert, key); + match Identity::from_pem(identity_pem.as_bytes()) { + Ok(identity) => { + builder = builder.identity(identity); + } + Err(e) => { + tracing::warn!("Failed to load mTLS identity for Vault service: {}", e); + } + } + } + + let http_client = builder .build() .map_err(|e| VaultError::Other(format!("Failed to create HTTP client: {}", e)))?; @@ -120,6 +135,8 @@ impl VaultService { /// - `VAULT_ADDRESS`: Base URL (e.g., https://vault.try.direct) /// - `VAULT_TOKEN`: Authentication token /// - `VAULT_CONFIG_PATH_PREFIX`: KV mount/prefix (e.g., secret/debug) + /// - `VAULT_CLIENT_CERT`: Client certificate PEM for mTLS + /// - `VAULT_CLIENT_KEY`: Client key PEM for mTLS pub fn from_env() -> Result, VaultError> { let base_url = std::env::var("VAULT_ADDRESS").ok(); let token = std::env::var("VAULT_TOKEN").ok(); @@ -129,8 +146,24 @@ impl VaultService { match (base_url, token, prefix) { (Some(base), Some(tok), Some(pref)) => { - let http_client = Client::builder() - .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)) + let mut builder = Client::builder() + .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)); + + let client_cert = std::env::var("VAULT_CLIENT_CERT").ok(); + let client_key = std::env::var("VAULT_CLIENT_KEY").ok(); + if let (Some(cert), Some(key)) = (&client_cert, &client_key) { + let identity_pem = format!("{}\n{}", cert, key); + match Identity::from_pem(identity_pem.as_bytes()) { + Ok(identity) => { + builder = builder.identity(identity); + } + Err(e) => { + tracing::warn!("Failed to load mTLS identity for Vault service from env: {}", e); + } + } + } + + let http_client = builder .build() .map_err(|e| { VaultError::Other(format!("Failed to create HTTP client: {}", e)) From 92ae1f6f69d86a7e241eb3059edf8c730e61ca63 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Sat, 27 Jun 2026 13:33:11 +0300 Subject: [PATCH 2/2] mTLS, stacker templates command and aliases to list all available templates from catalog --- Cargo.toml | 2 +- docs/MARKETPLACE_PUBLISH.md | 288 ++++++++++++++++++++++++ src/bin/stacker.rs | 18 ++ src/connectors/user_service/utils.rs | 33 ++- src/console/commands/cli/marketplace.rs | 128 ++++++++++- src/helpers/vault.rs | 11 +- src/routes/chat/get.rs | 2 +- src/routes/marketplace/search.rs | 70 +++++- src/services/vault_service.rs | 6 +- 9 files changed, 531 insertions(+), 27 deletions(-) create mode 100644 docs/MARKETPLACE_PUBLISH.md diff --git a/Cargo.toml b/Cargo.toml index 6cc6336e..127ac5df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ actix = "0.13.5" actix-web-actors = "4.3.1" chrono = { version = "0.4.39", features = ["serde", "clock"] } config = "0.13.4" -reqwest = { version = "0.11.23", features = ["json", "blocking", "stream"] } +reqwest = { version = "0.11.23", features = ["json", "blocking", "stream", "native-tls"] } serde = { version = "1.0.195", features = ["derive"] } tokio = { version = "1.28.1", features = ["full"] } tracing = { version = "0.1.40", features = ["log"] } diff --git a/docs/MARKETPLACE_PUBLISH.md b/docs/MARKETPLACE_PUBLISH.md new file mode 100644 index 00000000..5fcc6fd3 --- /dev/null +++ b/docs/MARKETPLACE_PUBLISH.md @@ -0,0 +1,288 @@ +# Publishing a Stack to the TryDirect Marketplace + +This guide walks creators through publishing a stack to the TryDirect marketplace +so other users can deploy it in one click and you earn on every sale. + +--- + +## Table of Contents + +- [Who this is for](#who-this-is-for) +- [What you get](#what-you-get) +- [Two ways to publish](#two-ways-to-publish) +- [Path A: Publish from Stack Builder (UI)](#path-a-publish-from-stack-builder-ui) +- [Path B: Publish from CLI (stacker.yml)](#path-b-publish-from-cli-stackeryml) +- [Required metadata](#required-metadata) +- [Pricing options](#pricing-options) +- [The review process](#the-review-process) +- [After approval](#after-approval) +- [Updating a published template](#updating-a-published-template) +- [Common rejection reasons](#common-rejection-reasons) +- [FAQ](#faq) + +--- + +## Who this is for + +You should publish to the marketplace if you have a working Docker Compose stack +or `stacker.yml` that: + +- Solves a clear business problem (e.g. "Internal AI Helpdesk", "RAG Knowledge Base") +- Wires multiple services together so buyers don't have to (database + cache + app + LLM, etc.) +- Has been tested end-to-end on at least one cloud provider + +Single-container stacks are accepted but multi-service bundles consistently +outperform them in deployments and revenue. + +--- + +## What you get + +- **75% revenue share** on every paid deployment, including subscription renewals +- **Monthly Net-30 payouts** via Stripe Connect or PayPal (minimum $50) +- Automatic marketplace promotion: SEO landing page at `/applications/`, + inclusion in weekly digests, "Trending" badge if your template gains traction +- A "Deploy with TryDirect" badge you can embed in your GitHub README +- Public deploy count and creator profile + +Full payout terms: see `config/docs/MARKETPLACE_PAYOUT_TERMS.md`. + +--- + +## Two ways to publish + +| Path | Best for | Effort | +|---|---|---| +| **Stack Builder (UI)** | Stacks you built visually inside TryDirect | Click "Publish to Marketplace" | +| **stacker.yml (CLI)** | Stacks defined in your own repo | `stacker publish` | + +Both paths produce the same `StackTemplate` record and follow the same review +process. Pick whichever fits how you author your stack. + +--- + +## Path A: Publish from Stack Builder (UI) + +1. Open your stack in **Stack Builder** at `/builder`. +2. Verify it deploys cleanly to a test server. +3. Click **Publish to Marketplace** in the project sidebar. +4. Fill in the publish form: + - **Name** — a business-oriented name, not just tech ("Client AI Agent Workspace", not "n8n+Qdrant+Ollama") + - **Short description** — one sentence: what problem does it solve? + - **Long description** — markdown supported; cover use cases, requirements, customisation + - **Category** — AI Agents, Data Pipelines, SaaS Starter, Dev Tools, etc. + - **Tags** — `n8n`, `qdrant`, `ollama`, `supabase`, `postgres`, etc. + - **License / pricing** — Free, Paid (one-time), or Subscription + - **Price** (if paid) — USD + - **Support URL** — GitHub repo, docs site, or contact form + - **No-secrets confirmation** — required checkbox: confirms you removed all + embedded credentials before submitting +5. Click **Submit to marketplace**. Your dashboard will show status: + `In review` → `Approved` or `Rejected (with reason)`. + +--- + +## Path B: Publish from CLI (stacker.yml) + +Add a `marketplace` section to your `stacker.yml`, then run `stacker publish`. + +### Example `stacker.yml` marketplace block + +```yaml +name: ai-helpdesk-starter +version: 1.0.0 + +# ... your existing app, services, deploy, etc. ... + +marketplace: + publish: true + display_name: "Internal AI Helpdesk" + short_description: "Self-hosted AI helpdesk with n8n workflows, Qdrant memory, and Ollama LLM." + long_description: | + Deploy a complete internal AI helpdesk stack: n8n handles ticket routing + and workflow automation, Qdrant stores conversation memory and document + embeddings, and Ollama serves the local LLM. + + Comes pre-wired with example workflows for common helpdesk patterns. + Customise via the n8n web UI after deployment. + category: ai-agents + tags: + - n8n + - qdrant + - ollama + - helpdesk + - rag + license: paid + pricing: + plan_type: one_time # one_time | subscription | free + price: 49 + currency: USD + support_url: https://github.com/your-org/ai-helpdesk-starter + no_secrets_confirmation: true +``` + +### Submit + +```bash +# From your project root +stacker publish + +# Stacker validates stacker.yml, packages the stack definition, +# and submits it to the TryDirect marketplace for review. +``` + +### Check status + +```bash +stacker publish --status + +# Shows: in_review | approved | rejected (with reason) +``` + +--- + +## Required metadata + +| Field | Required | Notes | +|---|---|---| +| Name | Yes | 5-80 chars, business-oriented | +| Short description | Yes | 20-200 chars, one sentence | +| Long description | Yes | Markdown, 100-5000 chars | +| Category | Yes | Must match an existing category code | +| Tags | Yes | 1-10 tags | +| License | Yes | `free`, `paid`, `subscription` | +| Price | If paid/subscription | USD, > 0 | +| Support URL | Yes | Public URL where buyers can reach you | +| No-secrets confirmation | Yes | Must be `true` | + +--- + +## Pricing options + +### Free +No revenue, but full marketplace promotion (SEO page, digest inclusion, trending +badges). Good for building reputation and audience. + +### One-time +Buyer pays once, deploys as many times as they want for their own use. +You earn 75% of the sale price. Most templates start here. + +### Subscription +Buyer pays monthly or yearly. You earn 75% of every renewal cycle, not just +the first sale. Best for templates that ship updates regularly. + +You can change pricing on future versions but not retroactively. Buyers of +v1.0.0 keep their original pricing for that version's lifetime. + +--- + +## The review process + +| Step | Time | Who | +|---|---|---| +| Submission received | Instant | Auto-confirmation email | +| Initial automated checks | < 1 hour | `stacker.yml` validation, no embedded secrets, no banned services | +| Manual review | 1-3 business days | TryDirect review team | +| Decision | — | Approved → live on `/applications`; Rejected → feedback in dashboard | + +Reviewers check: +1. **Deploys cleanly** on a fresh server +2. **No embedded credentials** in env vars, configs, or volumes +3. **No insecure defaults** (e.g. `--api.insecure=true`, `0.0.0.0/0` ACLs, hardcoded passwords) +4. **Metadata accurate** — what the listing claims matches what the stack actually does +5. **Support URL reachable** — opens to a real GitHub/docs/contact page + +--- + +## After approval + +Once approved, several things happen automatically: + +- **SEO landing page** generated at `/applications/` +- **Social post** to TryDirect Twitter/X: "New on TryDirect: